Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fff0703e0c | |||
| b1a279f30b |
@@ -1,9 +1,2 @@
|
||||
# Treat the AppIcon bundle's contents as binary and never merge them.
|
||||
web/electron/icons/AppIcon.icon/** binary -merge
|
||||
|
||||
# Protobuf bindings regenerated by scripts/gen_routing_pb2.py from the .proto
|
||||
# schema. Mark them generated so review/code-quality tooling skips them (ruff
|
||||
# and mypy already exclude them in pyproject.toml); the protoc output isn't
|
||||
# hand-editable, so its unused-import/global artifacts are expected.
|
||||
omnigent/api/**/*_pb2.py linguist-generated=true
|
||||
omnigent/api/**/*_pb2.pyi linguist-generated=true
|
||||
|
||||
@@ -21,4 +21,3 @@ shivam5
|
||||
TomeHirata
|
||||
xq-yin
|
||||
hzub
|
||||
zhengwin
|
||||
|
||||
+2
-4
@@ -24,7 +24,7 @@
|
||||
" - 'web/' before 'web/electron/' and 'web/ios/'",
|
||||
" - 'omnigent/inner/' before every 'omnigent/inner/<harness>_'.",
|
||||
" owners - candidate reviewers/assignees. Must be maintainers in",
|
||||
" .github/MAINTAINER. 2+ each incl. owners_paused. Edit these freely: the",
|
||||
" .github/MAINTAINER. 2+ each. Edit these freely: the",
|
||||
" reviewer-logic tests run against a frozen fixture",
|
||||
" (auto-assign-reviewer.fixture.json), so ownership changes here",
|
||||
" do not churn them. areas.test.js validates this file (every",
|
||||
@@ -181,9 +181,7 @@
|
||||
"omnigent/policies/"
|
||||
],
|
||||
"owners": [
|
||||
"TomeHirata"
|
||||
],
|
||||
"owners_paused": [
|
||||
"TomeHirata",
|
||||
"ckcuslife-source"
|
||||
]
|
||||
},
|
||||
|
||||
+75
-39
@@ -1,19 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Daily Discord-watch rotation reminder.
|
||||
|
||||
Reads an explicit dated schedule (rotation_schedule.json) plus a name ->
|
||||
slack_id/timezone roster (rotation_roster.json), finds today's assignee, and
|
||||
pings them in Slack on the morning of *their* local timezone.
|
||||
Picks the person on watch for the current day and pings them in Slack on the
|
||||
morning of *their* local timezone. The rotation is deterministic — the
|
||||
assignee is a function of the date and the person's position in the list — so
|
||||
there is no state to store anywhere.
|
||||
|
||||
The GitHub Actions workflow wakes at a couple of fixed UTC times (one per
|
||||
timezone's morning). On each run the day's assignee is pinged only if it's
|
||||
currently morning where they live; if not, the run for their timezone's
|
||||
morning handles them. Our timezones are far enough apart that only one is ever
|
||||
in its morning at a time, so at most one person is pinged per run. Dates not
|
||||
present in the schedule get no ping.
|
||||
in its morning at a time, so at most one person is pinged per run.
|
||||
|
||||
Set SLACK_WEBHOOK_URL to post for real. Leave it unset for a dry run that just
|
||||
prints what it would do — handy for testing the schedule without Slack.
|
||||
prints what it would do — handy for testing the rotation order without Slack.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,17 +21,11 @@ from __future__ import annotations
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
# Data files live alongside this script so they can be edited (swaps,
|
||||
# holidays, extending the schedule) without touching the logic here.
|
||||
ROSTER_PATH = pathlib.Path(__file__).with_name("rotation_roster.json")
|
||||
SCHEDULE_PATH = pathlib.Path(__file__).with_name("rotation_schedule.json")
|
||||
|
||||
# Each cron run is one timezone's morning scan: we ping today's assignee only
|
||||
# if it's currently morning where they are. A run that's morning in SF is night
|
||||
# in Singapore and vice versa, so at most one timezone matches per run. Morning
|
||||
@@ -43,56 +37,95 @@ SCHEDULE_PATH = pathlib.Path(__file__).with_name("rotation_schedule.json")
|
||||
MORNING_START_HOUR = 5
|
||||
MORNING_END_HOUR = 12
|
||||
|
||||
# Skip Saturdays and Sundays (in each person's local time). The rotation also
|
||||
# advances by workdays only, so Friday hands off straight to Monday.
|
||||
WEEKDAYS_ONLY = True
|
||||
|
||||
# Rotation anchor: workday 0 is this date. Any Monday works; it only sets the
|
||||
# phase of the cycle, not who is in it.
|
||||
EPOCH = datetime.date(2026, 1, 5) # a Monday
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Person:
|
||||
name: str # display name; matches the names used in the schedule
|
||||
name: str # for logs / dry-run output only
|
||||
slack_id: str # Slack member ID, e.g. "U01ABC2DEF" (NOT the display name)
|
||||
tz: str # IANA timezone name, e.g. "America/Los_Angeles"
|
||||
# Out-of-office spans as inclusive (start, end) ISO date pairs, e.g.
|
||||
# (("2026-07-13", "2026-07-17"),). On any OOO day the person is skipped and
|
||||
# the next available person covers; the OOO person keeps their later slots.
|
||||
ooo: tuple[tuple[str, str], ...] = ()
|
||||
|
||||
|
||||
def load_roster(roster_path: pathlib.Path = ROSTER_PATH) -> dict[str, Person]:
|
||||
"""Load the name -> Person mapping from JSON."""
|
||||
roster = json.loads(roster_path.read_text())
|
||||
return {
|
||||
name: Person(name=name, slack_id=entry["slack_id"], tz=entry["tz"])
|
||||
for name, entry in roster["people"].items()
|
||||
}
|
||||
# Rotation order. Slack member IDs (profile -> ⋮ More -> Copy member ID) and
|
||||
# each person's IANA timezone.
|
||||
PEOPLE: list[Person] = [
|
||||
Person("Aravind Segu", "U01A12R8NUR", "America/Los_Angeles"),
|
||||
Person("Bryan Qiu", "U05KA5T983Y", "America/Los_Angeles"),
|
||||
Person("Daniel Lok", "U060CNWNHSQ", "Asia/Singapore"),
|
||||
Person("Dhruv Gupta", "U0A76097E1F", "America/Los_Angeles"),
|
||||
Person("Edwin He", "U077B1V6WQJ", "America/Los_Angeles"),
|
||||
Person("Pat Sukprasert", "U05HRKWFY81", "Asia/Singapore"),
|
||||
Person("Sabhya Chhabria", "U07A1KQDXAB", "America/Los_Angeles"),
|
||||
Person("Serena Ruan", "U0571L5KNLR", "Asia/Singapore"),
|
||||
Person("Shivam Mittal", "U09FZKX9S6B", "America/Los_Angeles"),
|
||||
Person("Tomu Hirata", "U07TX4PR5MZ", "Asia/Singapore"),
|
||||
Person("Zeyi (Rice) Fan", "U09L5HT4CH0", "America/Los_Angeles"),
|
||||
]
|
||||
|
||||
|
||||
def load_schedule(
|
||||
schedule_path: pathlib.Path = SCHEDULE_PATH,
|
||||
) -> dict[datetime.date, str]:
|
||||
"""Load the date -> assignee-name mapping from JSON."""
|
||||
doc = json.loads(schedule_path.read_text())
|
||||
return {datetime.date.fromisoformat(row["date"]): row["name"] for row in doc["schedule"]}
|
||||
def _workdays_between(start: datetime.date, end: datetime.date) -> int:
|
||||
"""Number of Mon–Fri days in [start, end). Negative if end precedes start."""
|
||||
if end < start:
|
||||
return -_workdays_between(end, start)
|
||||
full_weeks, extra = divmod((end - start).days, 7)
|
||||
count = full_weeks * 5
|
||||
for i in range(extra):
|
||||
if (start + datetime.timedelta(days=full_weeks * 7 + i)).weekday() < 5:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
ROSTER: dict[str, Person] = load_roster()
|
||||
SCHEDULE: dict[datetime.date, str] = load_schedule()
|
||||
def is_ooo(person: Person, local_date: datetime.date) -> bool:
|
||||
"""Whether person is out of office on local_date (inclusive spans)."""
|
||||
for start, end in person.ooo:
|
||||
if datetime.date.fromisoformat(start) <= local_date <= datetime.date.fromisoformat(end):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def assignee_for(local_date: datetime.date) -> Person | None:
|
||||
"""The person scheduled for a given date, or None if the date isn't listed."""
|
||||
name = SCHEDULE.get(local_date)
|
||||
if name is None:
|
||||
return None
|
||||
return ROSTER.get(name)
|
||||
"""The person on watch for a given local workday, or None if all are OOO.
|
||||
|
||||
Indexed by the number of workdays since EPOCH (which is itself a Monday),
|
||||
so weekends advance nobody and Friday hands off directly to Monday. If the
|
||||
slot's person is OOO, the next available person covers — probing forward so
|
||||
coverage stays a pure function of the date (no stored state). Only
|
||||
meaningful for weekdays; weekends are filtered out before this is called.
|
||||
"""
|
||||
workday_number = _workdays_between(EPOCH, local_date)
|
||||
for offset in range(len(PEOPLE)):
|
||||
person = PEOPLE[(workday_number + offset) % len(PEOPLE)]
|
||||
if not is_ooo(person, local_date):
|
||||
return person
|
||||
return None # everyone is OOO that day
|
||||
|
||||
|
||||
def whose_turn_now(now_utc: datetime.datetime) -> Person | None:
|
||||
"""Return the person to ping right now, or None if it isn't anyone's morning.
|
||||
|
||||
Each person is evaluated in their own timezone: it must currently be morning
|
||||
(05:00–11:59) there, and today's schedule entry must name them. Since our
|
||||
Each person is evaluated in their own timezone: it must be a weekday morning
|
||||
(before noon) there, and today's rotation slot must land on them. Since our
|
||||
timezones are far enough apart that only one is ever in its morning at a
|
||||
time, at most one person matches. A person missed by a late/early run is
|
||||
picked up by the next run that lands in their morning.
|
||||
"""
|
||||
for person in ROSTER.values():
|
||||
for person in PEOPLE:
|
||||
local = now_utc.astimezone(ZoneInfo(person.tz))
|
||||
if not (MORNING_START_HOUR <= local.hour < MORNING_END_HOUR):
|
||||
continue
|
||||
if WEEKDAYS_ONLY and local.weekday() >= 5: # 5=Sat, 6=Sun
|
||||
continue
|
||||
if assignee_for(local.date()) == person:
|
||||
return person
|
||||
return None
|
||||
@@ -130,10 +163,13 @@ def _report_todays_assignees(now_utc: datetime.datetime) -> None:
|
||||
Runs regardless of the morning window so a manual run is always
|
||||
informative, even outside anyone's ping window.
|
||||
"""
|
||||
for tz in sorted({p.tz for p in ROSTER.values()}):
|
||||
for tz in sorted({p.tz for p in PEOPLE}):
|
||||
local = now_utc.astimezone(ZoneInfo(tz))
|
||||
person = assignee_for(local.date())
|
||||
who = person.name if person else "nobody (no schedule entry)"
|
||||
if local.weekday() >= 5: # 5=Sat, 6=Sun
|
||||
who = "nobody (weekend)"
|
||||
else:
|
||||
person = assignee_for(local.date())
|
||||
who = person.name if person else "nobody (all OOO)"
|
||||
print(f" {tz}: {local:%Y-%m-%d %a} -> {who}")
|
||||
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Maintain the Discord-watch schedule: prune elapsed dates, extend the horizon.
|
||||
|
||||
Keeps rotation_schedule.json a rolling window of upcoming weekdays. On each run
|
||||
it drops rows before today and appends new weekday rows — continuing the
|
||||
rotation order from wherever the schedule currently ends — until the schedule
|
||||
reaches HORIZON_DAYS ahead. Idempotent: running it twice in a row is a no-op
|
||||
once the horizon is full, and a missed run just gets caught up on the next one.
|
||||
|
||||
Manual edits (swaps, holiday coverage) on future dates are preserved — pruning
|
||||
only removes past dates, and extension only appends beyond the current last
|
||||
date, so it never rewrites a row a human changed.
|
||||
|
||||
Run with --check to exit non-zero when the file would change (no write), for a
|
||||
dry run in CI. Otherwise it rewrites the file in place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
ROSTER_PATH = pathlib.Path(__file__).with_name("rotation_roster.json")
|
||||
SCHEDULE_PATH = pathlib.Path(__file__).with_name("rotation_schedule.json")
|
||||
|
||||
# Keep the schedule filled this many days into the future.
|
||||
HORIZON_DAYS = 90
|
||||
|
||||
|
||||
def _roster_order(roster_path: pathlib.Path) -> list[str]:
|
||||
"""Rotation order = the order names appear in the roster JSON."""
|
||||
roster = json.loads(roster_path.read_text())
|
||||
return list(roster["people"].keys())
|
||||
|
||||
|
||||
def _next_weekday(date: datetime.date) -> datetime.date:
|
||||
"""The next Mon–Fri strictly after date."""
|
||||
nxt = date + datetime.timedelta(days=1)
|
||||
while nxt.weekday() >= 5: # 5=Sat, 6=Sun
|
||||
nxt += datetime.timedelta(days=1)
|
||||
return nxt
|
||||
|
||||
|
||||
def maintain(
|
||||
schedule_doc: dict,
|
||||
order: list[str],
|
||||
today: datetime.date,
|
||||
horizon_days: int = HORIZON_DAYS,
|
||||
) -> dict:
|
||||
"""Return a new schedule doc with past dates pruned and horizon extended."""
|
||||
rows = schedule_doc.get("schedule", [])
|
||||
|
||||
# Prune elapsed dates (keep today onward).
|
||||
kept = [r for r in rows if datetime.date.fromisoformat(r["date"]) >= today]
|
||||
kept.sort(key=lambda r: r["date"])
|
||||
|
||||
# Figure out where to resume the rotation.
|
||||
if kept:
|
||||
last_date = datetime.date.fromisoformat(kept[-1]["date"])
|
||||
last_idx = order.index(kept[-1]["name"]) if kept[-1]["name"] in order else -1
|
||||
else:
|
||||
# Empty (or fully elapsed) schedule: start today, at the top of the order.
|
||||
last_date = today - datetime.timedelta(days=1)
|
||||
last_idx = -1
|
||||
|
||||
horizon = today + datetime.timedelta(days=horizon_days)
|
||||
date = _next_weekday(last_date) if kept else _first_weekday_on_or_after(today)
|
||||
idx = last_idx
|
||||
while date <= horizon:
|
||||
idx = (idx + 1) % len(order)
|
||||
kept.append({"date": date.isoformat(), "name": order[idx]})
|
||||
date = _next_weekday(date)
|
||||
|
||||
new_doc = dict(schedule_doc)
|
||||
new_doc["schedule"] = kept
|
||||
return new_doc
|
||||
|
||||
|
||||
def _first_weekday_on_or_after(date: datetime.date) -> datetime.date:
|
||||
while date.weekday() >= 5:
|
||||
date += datetime.timedelta(days=1)
|
||||
return date
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="exit non-zero if the file would change; do not write",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--today",
|
||||
type=datetime.date.fromisoformat,
|
||||
default=datetime.date.today(),
|
||||
help="override today's date (ISO), for testing",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
doc = json.loads(SCHEDULE_PATH.read_text())
|
||||
order = _roster_order(ROSTER_PATH)
|
||||
new_doc = maintain(doc, order, args.today)
|
||||
|
||||
old_text = SCHEDULE_PATH.read_text()
|
||||
new_text = json.dumps(new_doc, indent=2) + "\n"
|
||||
|
||||
if old_text == new_text:
|
||||
print("Schedule already current; no change.")
|
||||
return 0
|
||||
|
||||
old_n = len(doc.get("schedule", []))
|
||||
new_n = len(new_doc["schedule"])
|
||||
print(
|
||||
f"Schedule updated: {old_n} -> {new_n} rows (through {new_doc['schedule'][-1]['date']})."
|
||||
)
|
||||
|
||||
if args.check:
|
||||
print("(--check) not writing.")
|
||||
return 1
|
||||
|
||||
SCHEDULE_PATH.write_text(new_text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"_readme": [
|
||||
"Discord-watch roster: the name -> Slack member ID + timezone mapping.",
|
||||
"Read by .github/scripts/rotation.py; the day-to-day schedule lives",
|
||||
"separately in rotation_schedule.json (a flat list of {date, name}).",
|
||||
"",
|
||||
"Fields per person (keyed by display name, which the schedule references):",
|
||||
" slack_id - Slack member ID (profile -> More -> Copy member ID), e.g.",
|
||||
" 'U01ABC2DEF'. NOT the @display-name; only the member ID",
|
||||
" actually notifies the person.",
|
||||
" tz - IANA timezone; the person is pinged on the morning of this",
|
||||
" zone. Currently 'America/Los_Angeles' or 'Asia/Singapore'.",
|
||||
"",
|
||||
"It is .json (not .yaml) on purpose: the CI runner has no PyYAML, so JSON",
|
||||
"is read natively by the stdlib (matches .github/areas.json)."
|
||||
],
|
||||
"people": {
|
||||
"Aravind Segu": { "slack_id": "U01A12R8NUR", "tz": "America/Los_Angeles" },
|
||||
"Bryan Qiu": { "slack_id": "U05KA5T983Y", "tz": "America/Los_Angeles" },
|
||||
"Daniel Lok": { "slack_id": "U060CNWNHSQ", "tz": "Asia/Singapore" },
|
||||
"Dhruv Gupta": { "slack_id": "U0A76097E1F", "tz": "America/Los_Angeles" },
|
||||
"Edwin He": { "slack_id": "U077B1V6WQJ", "tz": "America/Los_Angeles" },
|
||||
"Pat Sukprasert": { "slack_id": "U05HRKWFY81", "tz": "Asia/Singapore" },
|
||||
"Sabhya Chhabria": { "slack_id": "U07A1KQDXAB", "tz": "America/Los_Angeles" },
|
||||
"Serena Ruan": { "slack_id": "U0571L5KNLR", "tz": "Asia/Singapore" },
|
||||
"Shivam Mittal": { "slack_id": "U09FZKX9S6B", "tz": "America/Los_Angeles" },
|
||||
"Tomu Hirata": { "slack_id": "U07TX4PR5MZ", "tz": "Asia/Singapore" },
|
||||
"Zeyi (Rice) Fan": { "slack_id": "U09L5HT4CH0", "tz": "America/Los_Angeles" }
|
||||
}
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
{
|
||||
"_readme": [
|
||||
"Discord-watch schedule. Read by .github/scripts/rotation.py.",
|
||||
"",
|
||||
"One row per assigned weekday, in date order. On each run the bot finds the",
|
||||
"row whose date is today (in the assignee timezone) and pings that person on",
|
||||
"the morning of their timezone. Dates not listed here get no ping, so keep",
|
||||
"this topped up \u2014 extend it before it runs out.",
|
||||
"",
|
||||
"To swap or cover a holiday, just edit the name on the affected date(s).",
|
||||
"name must match an entry in rotation_roster.json (which holds the",
|
||||
"name -> slack_id + timezone mapping)."
|
||||
],
|
||||
"schedule": [
|
||||
{
|
||||
"date": "2026-07-14",
|
||||
"name": "Edwin He"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-15",
|
||||
"name": "Pat Sukprasert"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-16",
|
||||
"name": "Sabhya Chhabria"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-17",
|
||||
"name": "Serena Ruan"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-20",
|
||||
"name": "Shivam Mittal"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-21",
|
||||
"name": "Tomu Hirata"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-22",
|
||||
"name": "Zeyi (Rice) Fan"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-23",
|
||||
"name": "Aravind Segu"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-24",
|
||||
"name": "Bryan Qiu"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-27",
|
||||
"name": "Daniel Lok"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-28",
|
||||
"name": "Dhruv Gupta"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"name": "Edwin He"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30",
|
||||
"name": "Pat Sukprasert"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-31",
|
||||
"name": "Sabhya Chhabria"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-03",
|
||||
"name": "Serena Ruan"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-04",
|
||||
"name": "Shivam Mittal"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-05",
|
||||
"name": "Tomu Hirata"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-06",
|
||||
"name": "Zeyi (Rice) Fan"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-07",
|
||||
"name": "Aravind Segu"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-10",
|
||||
"name": "Bryan Qiu"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-11",
|
||||
"name": "Daniel Lok"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-12",
|
||||
"name": "Dhruv Gupta"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-13",
|
||||
"name": "Edwin He"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"name": "Pat Sukprasert"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-17",
|
||||
"name": "Sabhya Chhabria"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-18",
|
||||
"name": "Serena Ruan"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-19",
|
||||
"name": "Shivam Mittal"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"name": "Tomu Hirata"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-21",
|
||||
"name": "Zeyi (Rice) Fan"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"name": "Aravind Segu"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-25",
|
||||
"name": "Bryan Qiu"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-26",
|
||||
"name": "Daniel Lok"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-27",
|
||||
"name": "Dhruv Gupta"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-28",
|
||||
"name": "Edwin He"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-31",
|
||||
"name": "Pat Sukprasert"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-01",
|
||||
"name": "Sabhya Chhabria"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-02",
|
||||
"name": "Serena Ruan"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-03",
|
||||
"name": "Shivam Mittal"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-04",
|
||||
"name": "Tomu Hirata"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-07",
|
||||
"name": "Zeyi (Rice) Fan"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-08",
|
||||
"name": "Aravind Segu"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-09",
|
||||
"name": "Bryan Qiu"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-10",
|
||||
"name": "Daniel Lok"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-11",
|
||||
"name": "Dhruv Gupta"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-14",
|
||||
"name": "Edwin He"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-15",
|
||||
"name": "Pat Sukprasert"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-16",
|
||||
"name": "Sabhya Chhabria"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-17",
|
||||
"name": "Serena Ruan"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-18",
|
||||
"name": "Shivam Mittal"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-21",
|
||||
"name": "Tomu Hirata"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-22",
|
||||
"name": "Zeyi (Rice) Fan"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-23",
|
||||
"name": "Aravind Segu"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-24",
|
||||
"name": "Bryan Qiu"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-25",
|
||||
"name": "Daniel Lok"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-28",
|
||||
"name": "Dhruv Gupta"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-29",
|
||||
"name": "Edwin He"
|
||||
},
|
||||
{
|
||||
"date": "2026-09-30",
|
||||
"name": "Pat Sukprasert"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-01",
|
||||
"name": "Sabhya Chhabria"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-02",
|
||||
"name": "Serena Ruan"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-05",
|
||||
"name": "Shivam Mittal"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-06",
|
||||
"name": "Tomu Hirata"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-07",
|
||||
"name": "Zeyi (Rice) Fan"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-08",
|
||||
"name": "Aravind Segu"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-09",
|
||||
"name": "Bryan Qiu"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-12",
|
||||
"name": "Daniel Lok"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-13",
|
||||
"name": "Dhruv Gupta"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-14",
|
||||
"name": "Edwin He"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-15",
|
||||
"name": "Pat Sukprasert"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-16",
|
||||
"name": "Sabhya Chhabria"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -32,10 +32,9 @@ for (const a of areas)
|
||||
for (const a of areas)
|
||||
assert(`area ${a.key} label ${a.label} is a real comp:*`, ALLOWED_LABELS.has(a.label));
|
||||
|
||||
// Every area has >= 2 owners (the 2+ codeowner requirement). Paused owners
|
||||
// still count -- pausing someone must not force adding a new active owner.
|
||||
// Every area has >= 2 owners (the 2+ codeowner requirement).
|
||||
for (const a of areas) {
|
||||
const n = (a.owners || []).length + (a.owners_paused || []).length;
|
||||
const n = (a.owners || []).length;
|
||||
assert(`area ${a.key} has >= 2 owners`, n >= 2, `${n} owner(s)`);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,12 +63,12 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
backend: [sqlite, postgres, mysql]
|
||||
backend: [sqlite, postgres]
|
||||
services:
|
||||
# The Postgres and MySQL services are defined unconditionally (GitHub
|
||||
# Actions has no per-matrix-value service gating); each leg connects only
|
||||
# to its own backend and ignores the others. postgres:16 mirrors
|
||||
# Lakebase's major version; mysql:8.0 matches the stores-mysql CI lane.
|
||||
# A Postgres service is defined unconditionally (GitHub Actions has no
|
||||
# per-matrix-value service gating), but only the postgres leg connects to
|
||||
# it — the sqlite leg simply ignores it. postgres:16 mirrors Lakebase's
|
||||
# major version.
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
@@ -81,18 +81,6 @@ jobs:
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: bench
|
||||
MYSQL_DATABASE: benchdb
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd "mysqladmin ping -h 127.0.0.1 -u root -pbench"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
@@ -113,21 +101,11 @@ jobs:
|
||||
# `databricks` extra carries psycopg[binary] for the Postgres backend.
|
||||
run: uv sync --extra dev --extra databricks
|
||||
|
||||
- name: Install MySQL driver
|
||||
# mysqlclient (mysql+mysqldb://) needs the system client library and is
|
||||
# not in any extra, so install it only on the mysql leg. Matches the
|
||||
# stores-mysql lane in ci.yml.
|
||||
if: matrix.backend == 'mysql'
|
||||
run: |
|
||||
sudo apt-get update -qq && sudo apt-get install -y -q libmysqlclient-dev
|
||||
uv pip install mysqlclient
|
||||
|
||||
# Resolve the DB URI + a stable seed-cache key for this backend. The
|
||||
# cache key binds the DB schema head + seed.py contents + corpus config,
|
||||
# so a schema change or seed edit busts the cache and forces a reseed —
|
||||
# the "you changed the schema, refresh the seed" contract (SQLite only;
|
||||
# the Postgres/MySQL services are fresh each run so their DB is never
|
||||
# cached).
|
||||
# the Postgres service is fresh each run so its DB is never cached).
|
||||
- name: Resolve DB target
|
||||
id: db
|
||||
run: |
|
||||
@@ -135,9 +113,6 @@ jobs:
|
||||
if [[ "${{ matrix.backend }}" == "postgres" ]]; then
|
||||
echo "uri=postgresql+psycopg://postgres:bench@localhost:5432/benchdb" >> "$GITHUB_OUTPUT"
|
||||
echo "cache_path=" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${{ matrix.backend }}" == "mysql" ]]; then
|
||||
echo "uri=mysql+mysqldb://root:bench@127.0.0.1:3306/benchdb" >> "$GITHUB_OUTPUT"
|
||||
echo "cache_path=" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "uri=sqlite:///$PWD/bench.db" >> "$GITHUB_OUTPUT"
|
||||
echo "cache_path=bench.db" >> "$GITHUB_OUTPUT"
|
||||
@@ -145,7 +120,7 @@ jobs:
|
||||
echo "cache_key=benchdb-${{ matrix.backend }}-$HEAD-${SESSIONS}x${ITEMS}-${{ hashFiles('dev/benchmarks/omnigent/seed.py') }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Reuse a previously-seeded SQLite corpus when schema + seed + config are
|
||||
# unchanged. No-op for the server-backed legs (empty path).
|
||||
# unchanged. No-op for the postgres leg (empty path).
|
||||
- name: Restore seeded SQLite corpus
|
||||
if: matrix.backend == 'sqlite'
|
||||
id: seedcache
|
||||
@@ -155,10 +130,9 @@ jobs:
|
||||
key: ${{ steps.db.outputs.cache_key }}
|
||||
|
||||
- name: Seed corpus
|
||||
# The fresh-service backends (postgres, mysql) always seed; SQLite seeds
|
||||
# only on a cache miss. seed.py is itself idempotent, so a stray hit is
|
||||
# harmless.
|
||||
if: matrix.backend != 'sqlite' || steps.seedcache.outputs.cache-hit != 'true'
|
||||
# Postgres always seeds (fresh service each run); SQLite seeds only on a
|
||||
# cache miss. seed.py is itself idempotent, so a stray hit is harmless.
|
||||
if: matrix.backend == 'postgres' || steps.seedcache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
uv run --no-sync dev/benchmarks/omnigent/seed.py \
|
||||
--database-uri "${{ steps.db.outputs.uri }}" \
|
||||
|
||||
@@ -12,11 +12,9 @@ name: Bump Version
|
||||
# this workflow wraps it with `uv lock`, a consistency check, and an
|
||||
# auto-opened PR.
|
||||
#
|
||||
# NOTE: when the omnigent-ci App is configured (vars.OMNIGENT_BOT_APP_ID),
|
||||
# the branch is pushed and the PR opened with a short-lived App token, so CI
|
||||
# runs on the bump PR automatically. Without it (e.g. in forks) the
|
||||
# GITHUB_TOKEN fallback applies and, by GitHub policy, CI does NOT auto-run —
|
||||
# re-open the PR or push to it to kick CI.
|
||||
# NOTE: the PR is created with GITHUB_TOKEN, so by GitHub policy it does
|
||||
# NOT trigger other workflows (CI won't auto-run on it). Push an empty
|
||||
# commit or re-open the PR to kick CI, or swap in a PAT if that matters.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -89,21 +87,9 @@ jobs:
|
||||
- name: Verify all locations agree
|
||||
run: uv run --no-project --python 3.12 --with packaging python scripts/update_versions.py check
|
||||
|
||||
# A bump PR pushed by the App identity gets CI runs; a GITHUB_TOKEN push
|
||||
# would not (GitHub suppresses events from GITHUB_TOKEN-authored pushes).
|
||||
- name: Mint App token (omnigent)
|
||||
id: app-token
|
||||
if: vars.OMNIGENT_BOT_APP_ID != ''
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ vars.OMNIGENT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: omnigent
|
||||
|
||||
- name: Open bump PR
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
MODE: ${{ github.event.inputs.mode }}
|
||||
NEW_VERSION: ${{ github.event.inputs.new_version }}
|
||||
BASE: ${{ github.event.inputs.base_branch }}
|
||||
@@ -123,9 +109,6 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
git commit -s -m "Bump version to ${resolved}"
|
||||
# Push with the same token that opens the PR (see the App-token
|
||||
# note above); the checkout's persisted credential is GITHUB_TOKEN.
|
||||
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
||||
git push --force-with-lease origin "$branch"
|
||||
|
||||
existing="$(gh pr list --head "$branch" --base "$BASE" --json number --jq '.[0].number')"
|
||||
@@ -141,4 +124,4 @@ jobs:
|
||||
|
||||
Rewrote \`[project].version\` and sibling \`==\` pins across all three packages (\`pyproject.toml\`, \`sdks/python-client\`, \`sdks/ui\`), the runtime \`VERSION\` constant in \`omnigent/version.py\`, and regenerated \`uv.lock\`.
|
||||
|
||||
Generated by \`scripts/update_versions.py\`. Opened via the omnigent-ci App when configured (CI runs automatically); on the GITHUB_TOKEN fallback, re-open or push to kick CI."
|
||||
Generated by \`scripts/update_versions.py\`. CI does not auto-trigger on GITHUB_TOKEN PRs — re-open or push to run it."
|
||||
|
||||
@@ -12,14 +12,11 @@ name: CI
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths-ignore: ['web/**', 'tests/e2e_ui/**', 'CHANGELOG.md']
|
||||
paths-ignore: ['web/**', 'tests/e2e_ui/**']
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# Release branches: release.yml's green-CI gate reads check runs off the
|
||||
# branch head, so cherry-picks and release-bump commits must run CI.
|
||||
- 'branch-[0-9]*'
|
||||
paths-ignore: ['web/**', 'tests/e2e_ui/**', 'CHANGELOG.md']
|
||||
paths-ignore: ['web/**', 'tests/e2e_ui/**']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
name: Discord watch rotation - maintain schedule
|
||||
|
||||
# Monthly housekeeping for rotation_schedule.json: prune elapsed dates and
|
||||
# extend the horizon ~3 months out. Opens a PR rather than pushing to main, so
|
||||
# the change is reviewable and no write to a protected branch is needed.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 8 1 * *" # 08:00 UTC on the 1st of each month
|
||||
workflow_dispatch: {} # manual "Run workflow" button
|
||||
|
||||
# Needs to push a branch and open a PR; no other write scope.
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: discord-watch-rotation-maintain
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
extend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Update schedule
|
||||
id: update
|
||||
run: |
|
||||
if python3 .github/scripts/rotation_maintain.py; then
|
||||
if git diff --quiet -- .github/scripts/rotation_schedule.json; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "Schedule maintenance failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Open PR
|
||||
if: steps.update.outputs.changed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
branch="rotation-schedule-$(date -u +%Y%m%d)"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -b "$branch"
|
||||
git add .github/scripts/rotation_schedule.json
|
||||
git commit -m "chore(ci): extend Discord watch rotation schedule"
|
||||
git push -u origin "$branch"
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head "$branch" \
|
||||
--title "chore(ci): extend Discord watch rotation schedule" \
|
||||
--body "Automated monthly housekeeping: pruned elapsed dates and extended \`rotation_schedule.json\` ~3 months out. Generated by the discord-watch-rotation-maintain workflow."
|
||||
@@ -409,38 +409,13 @@ jobs:
|
||||
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Always end the notes with the community thanks. The AI drafter curates
|
||||
# freely (and can drop a hand-added line), so this is appended here rather
|
||||
# than via the prompt — every release, AI-drafted or mechanical fallback,
|
||||
# gets it. Idempotent, and placed just before the trailing "Full Changelog:"
|
||||
# link to match the layout of prior releases.
|
||||
python3 - <<'PYEOF'
|
||||
import pathlib
|
||||
NOTE = (
|
||||
"### 💜 Thanks to our community\n\n"
|
||||
"This release was shaped by the people who filed issues, opened PRs, and "
|
||||
"talked through feature requests with us on our Discord! Thank you for "
|
||||
"building omnigent with us, keep the bug reports, ideas and contributions "
|
||||
"coming :)"
|
||||
)
|
||||
path = pathlib.Path("/tmp/release_notes.md")
|
||||
text = path.read_text(encoding="utf-8").rstrip("\n")
|
||||
if "Thanks to our community" not in text:
|
||||
idx = text.find("\nFull Changelog:")
|
||||
if idx != -1:
|
||||
head, tail = text[:idx].rstrip("\n"), text[idx:].lstrip("\n")
|
||||
text = f"{head}\n\n{NOTE}\n\n{tail}"
|
||||
else:
|
||||
text = f"{text}\n\n{NOTE}"
|
||||
path.write_text(text + "\n", encoding="utf-8")
|
||||
PYEOF
|
||||
# github-release.yml seeds only a short placeholder body (no
|
||||
# auto-generated notes), so replace it wholesale with the curated notes.
|
||||
# Edit by release ID: a draft release can't be addressed by tag (the
|
||||
# get/edit-by-tag REST endpoint 404s until the release is published).
|
||||
gh api --method PATCH "repos/${SOURCE_REPO}/releases/${RELEASE_ID}" \
|
||||
--field body=@/tmp/release_notes.md > /dev/null
|
||||
echo "Enriched the ${TAG} release draft with curated notes + community note." \
|
||||
echo "Enriched the ${TAG} release draft with curated notes." \
|
||||
| tee -a "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ::add-mask:: redacts rendered logs, not artifact files — scrub the key
|
||||
|
||||
@@ -18,7 +18,6 @@ on:
|
||||
# Security Gate via rerun-security-gate.yml, so label churn need not re-run
|
||||
# the heavy Playwright suite. (#399 added these for the gate; superseded.)
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths-ignore: ['CHANGELOG.md']
|
||||
schedule:
|
||||
- cron: "0 9 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -23,7 +23,7 @@ on:
|
||||
# (rerun-security-gate-run.yml falls back to this trigger). The concurrency
|
||||
# group key isolates label events so they never cancel a code-push run.
|
||||
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
|
||||
paths-ignore: ['web/**', 'tests/e2e_ui/**', 'CHANGELOG.md']
|
||||
paths-ignore: ['web/**', 'tests/e2e_ui/**']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
# Publish a FINAL release's GitHub draft as Latest — the last release step,
|
||||
# run after the prod PyPI publish succeeded and the draft notes are curated
|
||||
# (designs/RELEASE-AUTOMATION.md).
|
||||
#
|
||||
# Deterministic gates first (all fail with actionable links):
|
||||
# * the tag is a final vX.Y.Z with an unpublished draft release,
|
||||
# * PyPI serves all three lockstep packages at the version (never advertise
|
||||
# a release that isn't installable),
|
||||
# * the auto/changelog/vX.Y.Z CHANGELOG PR isn't sitting open,
|
||||
# * the docs sweep: no open PRs against omnigent-site's X.Y-docs staging
|
||||
# branch (every doc staged this cycle is reviewed + merged/closed).
|
||||
#
|
||||
# The publish job binds the `publish-release` environment (one-time setup:
|
||||
# create it in repo settings with required reviewers). Approving it is the
|
||||
# human attestation "I reviewed the draft notes". The publish itself uses the
|
||||
# App token — GITHUB_TOKEN-published releases emit no `release: published`
|
||||
# event, and publish-changelog.yml + update-homebrew.yml hang off it — and
|
||||
# sets make_latest explicitly, which API publishes don't do on their own.
|
||||
#
|
||||
# rc tags never finalize: their drafts deliberately stay unpublished.
|
||||
name: Finalize release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Final release tag to publish as Latest, e.g. v0.6.0."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: finalize-release-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Maintainer-only, same gate as release.yml.
|
||||
authorize:
|
||||
if: github.repository == 'omnigent-ai/omnigent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Require admin/maintain role
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
role="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${ACTOR}/permission" --jq .role_name)"
|
||||
case "$role" in
|
||||
admin|maintain)
|
||||
echo "Dispatcher ${ACTOR} has role ${role} — authorized." | tee -a "$GITHUB_STEP_SUMMARY" ;;
|
||||
*)
|
||||
echo "::error::Release workflows require the admin or maintain role (dispatcher ${ACTOR} has '${role}')."
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
checks:
|
||||
needs: authorize
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
release_id: ${{ steps.draft.outputs.release_id }}
|
||||
already_published: ${{ steps.draft.outputs.already_published }}
|
||||
steps:
|
||||
- name: Require a final vX.Y.Z tag
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::${TAG} is not a final vX.Y.Z tag — rc/dev/alpha/beta releases never finalize."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Drafts are invisible to read-only tokens and unaddressable by tag
|
||||
# (the get-by-tag endpoint 404s on drafts) — resolve by listing with the
|
||||
# App token, same as draft-release-notes.yml. Scoped to BOTH repos: an
|
||||
# installation token cannot reach outside its grant, and the docs sweep
|
||||
# below queries omnigent-site.
|
||||
- name: Mint App token (omnigent + omnigent-site)
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ vars.OMNIGENT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: omnigent,omnigent-site
|
||||
|
||||
- name: Resolve the draft release
|
||||
id: draft
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
match="$(gh api "repos/${GITHUB_REPOSITORY}/releases" --paginate \
|
||||
--jq 'map(select(.tag_name == env.TAG)) | first // empty')"
|
||||
if [ -z "$match" ]; then
|
||||
echo "::error::No GitHub release found for ${TAG}. Did the tag push run github-release.yml?"
|
||||
exit 1
|
||||
fi
|
||||
is_draft="$(printf '%s' "$match" | jq -r '.draft')"
|
||||
release_id="$(printf '%s' "$match" | jq -r '.id')"
|
||||
already_published=false
|
||||
if [ "$is_draft" != "true" ]; then
|
||||
already_published=true
|
||||
echo "Release ${TAG} is already published — nothing to do (idempotent no-op)." \
|
||||
| tee -a "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
{
|
||||
echo "release_id=${release_id}"
|
||||
echo "already_published=${already_published}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Assert PyPI serves all three packages
|
||||
if: steps.draft.outputs.already_published != 'true'
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${TAG#v}"
|
||||
for pkg in omnigent omnigent-client omnigent-ui-sdk; do
|
||||
if ! curl -fsS "https://pypi.org/pypi/${pkg}/${version}/json" >/dev/null; then
|
||||
echo "::error::${pkg}==${version} is not on PyPI — run the secure-repo publish first (never advertise an uninstallable release)."
|
||||
exit 1
|
||||
fi
|
||||
echo "PyPI OK: ${pkg}==${version}"
|
||||
done
|
||||
|
||||
- name: Assert the CHANGELOG PR is not open
|
||||
if: steps.draft.outputs.already_published != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
open_pr="$(gh pr list --repo "$GITHUB_REPOSITORY" --head "auto/changelog/${TAG}" \
|
||||
--state open --json url --jq '.[0].url // empty')"
|
||||
if [ -n "$open_pr" ]; then
|
||||
echo "::error::The CHANGELOG PR for ${TAG} is still open — merge it first: ${open_pr}"
|
||||
exit 1
|
||||
fi
|
||||
echo "CHANGELOG PR for ${TAG}: merged or not needed."
|
||||
|
||||
- name: Docs sweep — no open PRs against the X.Y-docs staging branch
|
||||
if: steps.draft.outputs.already_published != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
SITE_REPO: ${{ github.repository_owner }}/omnigent-site
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${TAG#v}"
|
||||
docs_branch="${version%.*}-docs"
|
||||
open="$(gh pr list --repo "$SITE_REPO" --base "$docs_branch" --state open \
|
||||
--json url,title --jq '.[] | "- \(.url) \(.title)"')"
|
||||
if [ -n "$open" ]; then
|
||||
{
|
||||
echo "## Docs sweep failed for ${TAG}"
|
||||
echo ""
|
||||
echo "Open PRs still target \`${docs_branch}\` on ${SITE_REPO} — review and merge/close them, then re-dispatch:"
|
||||
echo "$open"
|
||||
} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
echo "::error::Open doc PRs still target ${docs_branch} — see the run summary."
|
||||
exit 1
|
||||
fi
|
||||
echo "Docs sweep clean: no open PRs against ${docs_branch}." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Approving this environment attests "I reviewed the curated draft notes".
|
||||
publish:
|
||||
needs: [authorize, checks]
|
||||
if: needs.checks.outputs.already_published != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
environment: publish-release
|
||||
steps:
|
||||
- name: Mint App token (omnigent)
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ vars.OMNIGENT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: omnigent
|
||||
|
||||
- name: Publish the draft as Latest
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
RELEASE_ID: ${{ needs.checks.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Edit by id (drafts 404 by tag). -F sends real booleans; make_latest
|
||||
# must be explicit — API publishes don't set it.
|
||||
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
|
||||
-F draft=false -f make_latest=true > /dev/null
|
||||
{
|
||||
echo "## Published ${TAG} as Latest"
|
||||
echo ""
|
||||
echo "The \`release: published\` event now fires (App-token publish):"
|
||||
echo "- **publish-changelog.yml** opens the omnigent-site release-post PR and the docs-publish PR — review and merge both."
|
||||
echo "- **update-homebrew.yml** opens the homebrew-tap bump PR — review the resource diff, then apply the \`pr-pull\` label."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -11,9 +11,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# Release branches: release.yml's green-CI gate reads check runs off the
|
||||
# branch head, so cherry-picks and release-bump commits must run checks.
|
||||
- 'branch-[0-9]*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -128,26 +125,3 @@ jobs:
|
||||
- name: Type-check web
|
||||
working-directory: web
|
||||
run: npm run type-check
|
||||
|
||||
# The three packages release in lockstep (identical versions + `==` sibling
|
||||
# pins). Assert agreement on every change so drift from a bad merge or
|
||||
# cherry-pick — however it happened — is caught before it reaches a release.
|
||||
version-lockstep:
|
||||
name: Version lockstep check
|
||||
needs: gate
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: All version locations agree
|
||||
run: |
|
||||
python -m pip install --quiet --disable-pip-version-check packaging
|
||||
python scripts/update_versions.py check
|
||||
|
||||
@@ -1,364 +0,0 @@
|
||||
# Cut or advance a release deterministically (designs/RELEASE-AUTOMATION.md):
|
||||
#
|
||||
# dispatch with version=0.6.0rc1 -> create branch-0.6 from `ref`, stamp the
|
||||
# lockstep version (scripts/update_versions.py + `uv lock`), tag v0.6.0rc1,
|
||||
# push branch + tag. Later dispatches (0.6.0rc2, 0.6.0, 0.6.1) reuse the
|
||||
# existing branch-0.6 head and ignore `ref`.
|
||||
#
|
||||
# The branch + tag are pushed with the omnigent-ci App token, NOT GITHUB_TOKEN:
|
||||
# GITHUB_TOKEN-pushed tags trigger no workflows by GitHub policy, and the whole
|
||||
# release chain (github-release.yml -> draft-release-notes.yml, and
|
||||
# oss-publish-images.yml) hangs off the tag push.
|
||||
#
|
||||
# PyPI publishing does NOT happen here — after this run, dispatch the secure
|
||||
# release repo on the tag (see RELEASING.md). Everything here is idempotent:
|
||||
# re-dispatch with identical inputs after any failure and it converges
|
||||
# (branch exists -> reused; version stamped -> no new commit; tag at the
|
||||
# converged commit -> no-op; tag anywhere else -> loud failure).
|
||||
#
|
||||
# `dry_run` defaults TRUE (repo convention, same as the vscode release
|
||||
# workflows): the plan job prints exactly what would happen; nothing is pushed.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to release, e.g. 0.6.0rc1 or 0.6.0 (no leading v)."
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
description: "Branch/tag/SHA to cut branch-X.Y from. Only consulted when the branch does not exist yet (rc1); later phases build from the existing branch head."
|
||||
required: false
|
||||
default: main
|
||||
type: string
|
||||
dry_run:
|
||||
description: "Plan only: validate + print what would happen, push nothing."
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
skip_ci_check:
|
||||
description: "Skip the green-CI assertion on the base commit (flaky-check escape hatch — use deliberately)."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Nothing here writes with GITHUB_TOKEN; pushes use the App token.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Serialize all release runs: two concurrent cuts (even of different versions)
|
||||
# could race the same branch-X.Y head.
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Releases are maintainer-only. `workflow_dispatch` is open to anyone with
|
||||
# write access, so gate on the dispatcher's actual repo role instead of a
|
||||
# hand-kept list. `github.actor` on a dispatch is the dispatcher.
|
||||
authorize:
|
||||
if: github.repository == 'omnigent-ai/omnigent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Require admin/maintain role
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
role="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${ACTOR}/permission" --jq .role_name)"
|
||||
case "$role" in
|
||||
admin|maintain)
|
||||
echo "Dispatcher ${ACTOR} has role ${role} — authorized." | tee -a "$GITHUB_STEP_SUMMARY" ;;
|
||||
*)
|
||||
echo "::error::Release workflows require the admin or maintain role (dispatcher ${ACTOR} has '${role}')."
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
# Resolve everything and validate BEFORE mutating anything. Runs checkout-free
|
||||
# (pure API reads) and also serves as the whole dry run.
|
||||
plan:
|
||||
needs: authorize
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
version: ${{ steps.derive.outputs.version }}
|
||||
tag: ${{ steps.derive.outputs.tag }}
|
||||
branch: ${{ steps.derive.outputs.branch }}
|
||||
prerelease: ${{ steps.derive.outputs.prerelease }}
|
||||
branch_exists: ${{ steps.state.outputs.branch_exists }}
|
||||
base_sha: ${{ steps.state.outputs.base_sha }}
|
||||
already_done: ${{ steps.state.outputs.already_done }}
|
||||
steps:
|
||||
- name: Validate version and derive names
|
||||
id: derive
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Final X.Y.Z or a PEP 440 pre-release (a/b/rc). No dev/post here.
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+)?$ ]]; then
|
||||
echo "::error::Invalid release version: ${VERSION} (expect 0.6.0 or 0.6.0rc1)"; exit 1
|
||||
fi
|
||||
major="${VERSION%%.*}"; rest="${VERSION#*.}"; minor="${rest%%.*}"
|
||||
prerelease=false
|
||||
case "$VERSION" in *a[0-9]*|*b[0-9]*|*rc[0-9]*) prerelease=true ;; esac
|
||||
{
|
||||
echo "version=${VERSION}"
|
||||
echo "tag=v${VERSION}"
|
||||
echo "branch=branch-${major}.${minor}"
|
||||
echo "prerelease=${prerelease}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve branch, base commit, and tag state
|
||||
id: state
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.derive.outputs.version }}
|
||||
TAG: ${{ steps.derive.outputs.tag }}
|
||||
BRANCH: ${{ steps.derive.outputs.branch }}
|
||||
REF: ${{ inputs.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# `gh api` prints the error body to STDOUT on 404, so capturing with
|
||||
# `|| true` would treat the "Not Found" JSON as an existing ref —
|
||||
# gate on the exit code instead.
|
||||
if branch_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${BRANCH}" --jq .object.sha 2>/dev/null)"; then
|
||||
branch_exists=true
|
||||
base_sha="$branch_sha"
|
||||
# `ref` only applies at branch creation. An explicit non-default ref
|
||||
# that disagrees with the branch head is a mistake, not a retarget.
|
||||
if [ "$REF" != "main" ]; then
|
||||
ref_sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${REF}" --jq .sha)"
|
||||
if [ "$ref_sha" != "$branch_sha" ]; then
|
||||
echo "::error::${BRANCH} already exists at ${branch_sha}; ref=${REF} (${ref_sha}) would not be used. Re-dispatch without ref, or delete the branch if this is recovery."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
branch_exists=false
|
||||
base_sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${REF}" --jq .sha)"
|
||||
fi
|
||||
|
||||
# Tag state: absent -> normal; at the converged release commit ->
|
||||
# no-op; anywhere else -> refuse (never silently move a tag).
|
||||
already_done=false
|
||||
if tag_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq .object.sha 2>/dev/null)"; then
|
||||
tag_type="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq .object.type)"
|
||||
if [ "$tag_type" = "tag" ]; then
|
||||
tag_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_sha}" --jq .object.sha)"
|
||||
fi
|
||||
stamped="$(gh api -H "Accept: application/vnd.github.raw+json" \
|
||||
"repos/${GITHUB_REPOSITORY}/contents/pyproject.toml?ref=${TAG}" \
|
||||
| sed -n 's/^version = "\(.*\)"$/\1/p' | head -1)"
|
||||
if [ "$tag_sha" = "$base_sha" ] && [ "$stamped" = "$VERSION" ]; then
|
||||
already_done=true
|
||||
echo "Tag ${TAG} already at the converged release commit ${base_sha} — nothing to do." \
|
||||
| tee -a "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "::error::Tag ${TAG} already exists at ${tag_sha} (stamped version: ${stamped:-unknown}), which is not the converged branch head ${base_sha}. Delete the tag first if this is recovery (see RELEASING.md)."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
{
|
||||
echo "branch_exists=${branch_exists}"
|
||||
echo "base_sha=${base_sha}"
|
||||
echo "already_done=${already_done}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Assert green CI on the base commit
|
||||
if: steps.state.outputs.already_done != 'true' && !inputs.skip_ci_check
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BASE_SHA: ${{ steps.state.outputs.base_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
runs="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${BASE_SHA}/check-runs?per_page=100" \
|
||||
--paginate --jq '.check_runs[] | [.name, .status, .conclusion // "-"] | @tsv')"
|
||||
total="$(printf '%s' "$runs" | grep -c . || true)"
|
||||
pending="$(printf '%s' "$runs" | awk -F'\t' '$2 != "completed"' || true)"
|
||||
# Cancelled runs are chronically present on main (superseded
|
||||
# benchmark/eval runs) — warn, don't block; real failures still gate.
|
||||
bad="$(printf '%s' "$runs" | awk -F'\t' '$3 ~ /^(failure|timed_out|action_required|startup_failure)$/' || true)"
|
||||
cancelled="$(printf '%s' "$runs" | awk -F'\t' '$3 == "cancelled"' || true)"
|
||||
if [ -n "$bad" ]; then
|
||||
echo "::error::Failing check runs on ${BASE_SHA}:"; printf '%s\n' "$bad"; exit 1
|
||||
fi
|
||||
if [ -n "$pending" ]; then
|
||||
echo "::error::Check runs still running on ${BASE_SHA} — wait for CI:"; printf '%s\n' "$pending"; exit 1
|
||||
fi
|
||||
if [ "$total" -eq 0 ]; then
|
||||
echo "::error::No check runs found on ${BASE_SHA}. Wait for CI on that commit, or re-dispatch with skip_ci_check=true if you are sure."
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$cancelled" ]; then
|
||||
echo "::warning::Cancelled (superseded) check runs on ${BASE_SHA} — not blocking:"
|
||||
printf '%s\n' "$cancelled"
|
||||
fi
|
||||
echo "CI green on ${BASE_SHA} (${total} completed check runs, none failing)." \
|
||||
| tee -a "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Write the plan
|
||||
env:
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
VERSION: ${{ steps.derive.outputs.version }}
|
||||
TAG: ${{ steps.derive.outputs.tag }}
|
||||
BRANCH: ${{ steps.derive.outputs.branch }}
|
||||
BRANCH_EXISTS: ${{ steps.state.outputs.branch_exists }}
|
||||
BASE_SHA: ${{ steps.state.outputs.base_sha }}
|
||||
ALREADY_DONE: ${{ steps.state.outputs.already_done }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "## Release plan for ${TAG}"
|
||||
echo ""
|
||||
echo "| | |"
|
||||
echo "| --- | --- |"
|
||||
echo "| Version | \`${VERSION}\` |"
|
||||
echo "| Branch | \`${BRANCH}\` ($([ "$BRANCH_EXISTS" = "true" ] && echo "exists — reused" || echo "will be created")) |"
|
||||
echo "| Base commit | \`${BASE_SHA}\` |"
|
||||
echo "| Converged already | ${ALREADY_DONE} |"
|
||||
echo "| Mode | $([ "$DRY_RUN" = "true" ] && echo "DRY RUN — nothing pushed" || echo "EXECUTE") |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Stamp + tag + push. Only reached on a real run that isn't already converged.
|
||||
cut:
|
||||
needs: [authorize, plan]
|
||||
if: ${{ !inputs.dry_run && needs.plan.outputs.already_done != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
# Clean public resolution for `uv lock` — the committed lockfile must
|
||||
# reference https://pypi.org/simple (never a proxy).
|
||||
UV_INDEX_URL: https://pypi.org/simple
|
||||
PIP_INDEX_URL: https://pypi.org/simple
|
||||
steps:
|
||||
- name: Mint App token (omnigent)
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ vars.OMNIGENT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: omnigent
|
||||
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# Existing branch: its head. New branch: the resolved base commit.
|
||||
ref: ${{ needs.plan.outputs.branch_exists == 'true' && needs.plan.outputs.branch || needs.plan.outputs.base_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: false
|
||||
|
||||
- name: Stamp the lockstep version
|
||||
env:
|
||||
VERSION: ${{ needs.plan.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
current="$(uv run --no-project --python 3.12 --with packaging \
|
||||
python scripts/update_versions.py check 2>/dev/null || true)"
|
||||
if [ "$current" = "$VERSION" ]; then
|
||||
echo "Already stamped at ${VERSION} — skipping bump (idempotent re-run)."
|
||||
else
|
||||
uv run --no-project --python 3.12 --with packaging \
|
||||
python scripts/update_versions.py pre-release --new-version "$VERSION"
|
||||
uv lock
|
||||
fi
|
||||
uv run --no-project --python 3.12 --with packaging \
|
||||
python scripts/update_versions.py check --expect "$VERSION"
|
||||
|
||||
- name: Commit, tag, and push
|
||||
env:
|
||||
PUSH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
VERSION: ${{ needs.plan.outputs.version }}
|
||||
TAG: ${{ needs.plan.outputs.tag }}
|
||||
BRANCH: ${{ needs.plan.outputs.branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "omnigent-ci[bot]"
|
||||
git config user.email "294685417+omnigent-ci[bot]@users.noreply.github.com"
|
||||
|
||||
git add pyproject.toml sdks/python-client/pyproject.toml sdks/ui/pyproject.toml \
|
||||
omnigent/version.py uv.lock
|
||||
if git diff --cached --quiet; then
|
||||
echo "No version changes to commit (already stamped)."
|
||||
else
|
||||
git commit -s -m "release: ${TAG}"
|
||||
fi
|
||||
|
||||
git tag "$TAG"
|
||||
# One push for branch + tag, via the App token so the tag-push
|
||||
# workflows fire. Non-fast-forward on the branch fails loudly.
|
||||
push_url="https://x-access-token:${PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
||||
git push "$push_url" "HEAD:refs/heads/${BRANCH}" "refs/tags/${TAG}"
|
||||
echo "Pushed ${BRANCH} + ${TAG} at $(git rev-parse HEAD)." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Next steps
|
||||
env:
|
||||
TAG: ${{ needs.plan.outputs.tag }}
|
||||
PRERELEASE: ${{ needs.plan.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "## Next steps"
|
||||
echo ""
|
||||
echo "1. Dispatch the secure-release repo on this tag:"
|
||||
echo ' ```'
|
||||
echo " gh workflow run omnigent.yml --repo databricks/secure-public-registry-releases-eng \\"
|
||||
echo " -f ref=${TAG} -f destination=pypi -f dry-run=true # gates rehearsal"
|
||||
echo " gh workflow run omnigent.yml --repo databricks/secure-public-registry-releases-eng \\"
|
||||
echo " -f ref=${TAG} -f destination=pypi -f dry-run=false # real publish"
|
||||
echo ' ```'
|
||||
if [ "$PRERELEASE" = "true" ]; then
|
||||
echo "2. Validate the rc from PyPI (see RELEASING.md). The GitHub draft for ${TAG} stays unpublished."
|
||||
else
|
||||
echo "2. Merge the CHANGELOG PR, curate the ${TAG} draft notes, then dispatch finalize-release.yml (tag=${TAG})."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# First cut of a cycle (rc1) immediately moves main to the next .dev0 so main
|
||||
# never re-freezes and doc-sync keeps deriving the right X.Y-docs branch.
|
||||
bump-main:
|
||||
needs: [authorize, plan, cut]
|
||||
if: ${{ !inputs.dry_run && needs.plan.outputs.branch_exists == 'false' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Dispatch the post-release main bump
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.plan.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# A cut below main's current line (a throwaway rehearsal rc, or
|
||||
# resurrecting an old series for a backport) must not walk main's
|
||||
# version backwards.
|
||||
MAIN_VERSION="$(gh api -H "Accept: application/vnd.github.raw+json" \
|
||||
"repos/${GITHUB_REPOSITORY}/contents/pyproject.toml?ref=main" \
|
||||
| sed -n 's/^version = "\(.*\)"$/\1/p' | head -1)"
|
||||
export MAIN_VERSION
|
||||
python3 -m pip install --quiet --disable-pip-version-check packaging
|
||||
if ! python3 -c 'import os, sys; from packaging.version import Version; sys.exit(0 if Version(os.environ["VERSION"]) > Version(os.environ["MAIN_VERSION"]) else 1)'; then
|
||||
echo "Released ${VERSION} sorts below main's ${MAIN_VERSION} — skipping the main bump." \
|
||||
| tee -a "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
gh workflow run bump-version.yml --repo "$GITHUB_REPOSITORY" \
|
||||
-f mode=post-release -f "new_version=${VERSION}" -f base_branch=main
|
||||
echo "Dispatched bump-version.yml (post-release ${VERSION}) — review and merge the main bump PR." \
|
||||
| tee -a "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,247 +0,0 @@
|
||||
# Open the omnigent-ai/homebrew-tap version-bump PR when a FINAL release is
|
||||
# published (designs/RELEASE-AUTOMATION.md). This is the missing link that let
|
||||
# the tap freeze while PyPI moved on: the tap already builds bottles on every
|
||||
# PR (brew test-bot) and publishes them on the `pr-pull` label — nobody was
|
||||
# opening the bump PR.
|
||||
#
|
||||
# What it does: wait for the new sdist on PyPI, rewrite the formula's
|
||||
# url/sha256 (dropping any bottle `revision`), regenerate the pinned Python
|
||||
# resources with `brew update-python-resources`, sanity-check that the
|
||||
# hand-maintained sections survived, and open the tap PR. A human reviews the
|
||||
# resource diff and applies `pr-pull`; the tap's own automation bottles and
|
||||
# merges. The omnigent-desktop cask is `version :latest` and needs nothing.
|
||||
#
|
||||
# Pre-releases never reach the tap. The `release: published` trigger fires
|
||||
# from finalize-release.yml's App-token publish; `workflow_dispatch` covers
|
||||
# retries and catch-up (e.g. jumping the formula straight to the newest
|
||||
# version after a missed cycle).
|
||||
name: Update Homebrew tap
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Final release tag to bump the tap to, e.g. v0.6.0."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: update-homebrew-${{ github.event.release.tag_name || inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
resolve:
|
||||
name: Resolve release tag
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
tag: ${{ steps.r.outputs.tag }}
|
||||
is_final: ${{ steps.r.outputs.is_final }}
|
||||
steps:
|
||||
- name: Resolve tag and finality
|
||||
id: r
|
||||
env:
|
||||
EVENT_TAG: ${{ github.event.release.tag_name }}
|
||||
PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${INPUT_TAG:-$EVENT_TAG}"
|
||||
is_final=true
|
||||
case "$tag" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) is_final=false ;;
|
||||
esac
|
||||
case "$tag" in
|
||||
*rc*|*dev*|*a[0-9]*|*b[0-9]*) is_final=false ;;
|
||||
esac
|
||||
if [ "${PRERELEASE}" = "true" ]; then
|
||||
is_final=false
|
||||
fi
|
||||
{
|
||||
echo "tag=${tag}"
|
||||
echo "is_final=${is_final}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved tag=${tag} is_final=${is_final}" | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
bump:
|
||||
name: Open tap bump PR
|
||||
needs: resolve
|
||||
# Canonical repo only; skip cleanly where the App isn't configured. The
|
||||
# release-event path is already gated by finalize-release's environment
|
||||
# approval; only manual dispatches need the role check below.
|
||||
if: >-
|
||||
needs.resolve.outputs.is_final == 'true' &&
|
||||
github.repository == 'omnigent-ai/omnigent' &&
|
||||
vars.OMNIGENT_BOT_APP_ID != ''
|
||||
# macOS: `brew update-python-resources` evaluates the formula (with its
|
||||
# on_macos blocks) in a real Homebrew.
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
TAP_REPO: ${{ github.repository_owner }}/homebrew-tap
|
||||
steps:
|
||||
- name: Require admin/maintain role (manual dispatches)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
role="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${ACTOR}/permission" --jq .role_name)"
|
||||
case "$role" in
|
||||
admin|maintain)
|
||||
echo "Dispatcher ${ACTOR} has role ${role} — authorized." ;;
|
||||
*)
|
||||
echo "::error::Release workflows require the admin or maintain role (dispatcher ${ACTOR} has '${role}')."
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Wait for the sdist on PyPI
|
||||
id: sdist
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${TAG#v}"
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
for _ in $(seq 1 30); do
|
||||
if json="$(curl -fsS "https://pypi.org/pypi/omnigent/${version}/json" 2>/dev/null)"; then
|
||||
url="$(printf '%s' "$json" | jq -r '.urls[] | select(.packagetype == "sdist") | .url')"
|
||||
sha="$(printf '%s' "$json" | jq -r '.urls[] | select(.packagetype == "sdist") | .digests.sha256')"
|
||||
if [ -n "$url" ] && [ -n "$sha" ]; then
|
||||
{
|
||||
echo "url=${url}"
|
||||
echo "sha=${sha}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "sdist for ${version}: ${url}"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "omnigent==${version} not visible on PyPI yet — retrying in 20s…"
|
||||
sleep 20
|
||||
done
|
||||
echo "::error::omnigent==${version} never appeared on PyPI (is the secure-repo publish done?)."
|
||||
exit 1
|
||||
|
||||
- name: Mint App token (homebrew-tap)
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ vars.OMNIGENT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: homebrew-tap
|
||||
|
||||
- name: Checkout the tap
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: ${{ env.TAP_REPO }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
path: tap
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Homebrew
|
||||
uses: Homebrew/actions/setup-homebrew@18fcb8e3e06b4247c676c506750dc95ea7226479 # 2026-07-10
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Rewrite the formula's stable url/sha256
|
||||
working-directory: tap
|
||||
env:
|
||||
SDIST_URL: ${{ steps.sdist.outputs.url }}
|
||||
SDIST_SHA: ${{ steps.sdist.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PYEOF'
|
||||
import os, pathlib, re
|
||||
path = pathlib.Path("Formula/omnigent.rb")
|
||||
text = path.read_text(encoding="utf-8")
|
||||
# The formula's own url/sha256 sit at 2-space indent; resource and
|
||||
# bottle entries are deeper, so first-match at this indent is safe.
|
||||
text, n_url = re.subn(r'(?m)^ url ".*"$', f' url "{os.environ["SDIST_URL"]}"', text, count=1)
|
||||
text, n_sha = re.subn(r'(?m)^ sha256 ".*"$', f' sha256 "{os.environ["SDIST_SHA"]}"', text, count=1)
|
||||
text, _ = re.subn(r'(?m)^ revision \d+\n', "", text, count=1)
|
||||
assert n_url == 1 and n_sha == 1, f"unexpected formula shape (url={n_url}, sha={n_sha})"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
PYEOF
|
||||
git diff --stat
|
||||
|
||||
- name: Regenerate the pinned Python resources
|
||||
env:
|
||||
HOMEBREW_NO_AUTO_UPDATE: "1"
|
||||
HOMEBREW_NO_INSTALL_FROM_API: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Make the checkout visible to brew as the real tap.
|
||||
tap_root="$(brew --repository)/Library/Taps/omnigent-ai"
|
||||
mkdir -p "$tap_root"
|
||||
ln -sfn "${GITHUB_WORKSPACE}/tap" "${tap_root}/homebrew-tap"
|
||||
# Excluded packages stay hand-maintained in the formula: the brewed
|
||||
# deps (certifi/cryptography/pydantic/rpds-py and their transitive
|
||||
# cffi/pycparser) and the platform-conditional google-antigravity
|
||||
# wheel stanzas.
|
||||
brew update-python-resources \
|
||||
--exclude-packages=certifi,cryptography,pydantic,rpds-py,cffi,pycparser,google-antigravity \
|
||||
omnigent-ai/tap/omnigent
|
||||
brew style omnigent-ai/tap/omnigent
|
||||
|
||||
- name: Assert the hand-maintained sections survived
|
||||
working-directory: tap
|
||||
run: |
|
||||
set -euo pipefail
|
||||
fail=0
|
||||
for needle in 'resource "google-antigravity"' 'depends_on "pydantic"' 'depends_on "cryptography"'; do
|
||||
if ! grep -qF "$needle" Formula/omnigent.rb; then
|
||||
echo "::error::update-python-resources dropped: ${needle} — fix the formula by hand this cycle."
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
[ "$fail" -eq 0 ]
|
||||
# The lockstep siblings must have moved with the release. Match the
|
||||
# sdist filename (PEP 503-normalized name + version) in the resource
|
||||
# url, not a bare version substring.
|
||||
version="${TAG#v}"
|
||||
for sib in omnigent-client omnigent-ui-sdk; do
|
||||
if ! grep -A2 "resource \"${sib}\"" Formula/omnigent.rb | grep -q "${sib//-/_}-${version}"; then
|
||||
echo "::error::resource ${sib} did not update to ${version}."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Open or update the tap bump PR
|
||||
working-directory: tap
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
VERSION: ${{ steps.sdist.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$(git status --porcelain -- Formula/omnigent.rb)" ]; then
|
||||
echo "Formula already at ${VERSION} — nothing to do." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
BRANCH="bump-omnigent-${VERSION}"
|
||||
git config user.name "omnigent-ci[bot]"
|
||||
git config user.email "294685417+omnigent-ci[bot]@users.noreply.github.com"
|
||||
PUSH_URL="https://x-access-token:${GH_TOKEN}@github.com/${TAP_REPO}.git"
|
||||
git switch -C "$BRANCH"
|
||||
git add Formula/omnigent.rb
|
||||
git commit -m "omnigent ${VERSION}"
|
||||
git push --force "$PUSH_URL" "$BRANCH"
|
||||
|
||||
if [ -n "$(gh pr list --repo "$TAP_REPO" --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then
|
||||
echo "Bump PR already open for ${BRANCH} — force-push updated it." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
body="$(printf 'Bumps the omnigent formula to **%s** (new sdist url/sha256, resources regenerated via `brew update-python-resources`).\n\ntest-bot builds the bottles on this PR. Review the resource diff — especially that the extras'"'"' deps survived — then apply the `pr-pull` label to publish bottles and merge.\n\nOpened by omnigent `.github/workflows/update-homebrew.yml`.' "$VERSION")"
|
||||
gh pr create \
|
||||
--repo "$TAP_REPO" \
|
||||
--base main \
|
||||
--head "$BRANCH" \
|
||||
--title "omnigent ${VERSION}" \
|
||||
--body "$body"
|
||||
echo "Opened tap bump PR for omnigent ${VERSION}." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
@@ -105,16 +105,6 @@ repos:
|
||||
files: ^uv\.lock$
|
||||
pass_filenames: true
|
||||
|
||||
# Fail if routing.proto changed without regenerating the committed
|
||||
# bindings (or vice versa). Verify-only, not a fixer: regen needs
|
||||
# grpcio-tools, so CI's `uv sync --extra dev` enforces it (like ktlint).
|
||||
- id: routing-pb2-fresh
|
||||
name: routing protobuf bindings are up to date
|
||||
language: system
|
||||
entry: .venv/bin/python scripts/gen_routing_pb2.py --check
|
||||
files: ^omnigent/api/routing/v1/routing(\.proto|_pb2\.pyi?)$
|
||||
pass_filenames: false
|
||||
|
||||
# ── File hygiene ────────────────────────────────────────────────
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
|
||||
@@ -5,97 +5,6 @@ generated at release time from each PR's `## Changelog` section, tagged by the
|
||||
PR's `Type of change` (e.g. `[UI]`); the concise, curated highlights live on the
|
||||
website under `/releases`.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Features
|
||||
|
||||
- [UI / Feature] Added a Nord color theme (arctic frost-blue palette) to the Appearance settings palette picker.
|
||||
|
||||
## [v0.5.0] — 2026-07-10
|
||||
|
||||
- [Bug fix] Messaging a long-idle session no longer risks the new turn being killed mid-flight by the idle reaper (#1834)
|
||||
- [UI / Feature] Introduce more secure sharing modes and the ability to toggle public chats on/off. (#1835)
|
||||
- [UI / Feature] Added: `.ipynb` notebooks render as read-only previews in the workspace file viewer (raw JSON still available via the source view) (#1848)
|
||||
- [Feature] `OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION=1` lets OIDC logins through when the IdP omits the `email_verified` claim (e.g. standard-tier Okta with directory-provisioned users) (#1859)
|
||||
- [UI / Feature] User message bubbles now have a copy button, matching assistant responses (#1900)
|
||||
- [UI] Renamed the sidebar's "Chats" section to "Sessions" to match the "New session" button (#1903)
|
||||
- [UI / Bug fix] Brain-harness override (e.g. claude-sdk vs openai-agents) is now remembered across sessions per agent (#1904)
|
||||
- [UI / Bug fix] "Back to Omnigent" from Settings now returns you to the conversation you were viewing instead of the home page (#1905)
|
||||
- [Bug fix] Release notes now list only user-facing bug fixes and call out breaking changes in their own section (#1909)
|
||||
- [Test/CI] Auto-drafted docs now stage on a per-minor `X.Y-docs` branch and publish to the live site at release, instead of deploying on merge. (#1915)
|
||||
- [UI] Removed the collapse toggle from the Files panel "Working folder" header — the file list is always visible (#1916)
|
||||
- [UI / Bug fix] Opencode agents addressed as `native-opencode` now render with their native terminal UI instead of falling back to plain chat. (#1929)
|
||||
- [Bug fix / Chore] Fixed harness workers (claude, codex, etc.) failing to start when omnigent is launched from a macOS or Linux GUI client due to a stripped PATH. Fix now lives in the Electron launcher (web/electron/src/main.js) per reviewer guidance. (#1935)
|
||||
- [Feature] Child-session lookup by `(agent, title)` now filters server-side instead of fetching all children and scanning in Python. (#1944)
|
||||
- [Bug fix] Sandboxed claude-sdk harnesses now authenticate from an existing host Claude login (`~/.claude/.credentials.json` is bound into the sandbox). (#1946)
|
||||
- [Chore / Test/CI] Runner MCP servers are shared across matching agent specs and started lazily to reduce local memory use. (#1948)
|
||||
- [Bug fix] Fixed: resumed claude-native sessions no longer crash on compaction ("Cannot destructure property 'cumulativeDroppedTokens'") (#1957)
|
||||
- [UI / Feature] The Claude model picker now offers Fable and both Sonnet generations (Sonnet 5 and Sonnet 4.6) as separate selections (#1981)
|
||||
- [Bug fix] Stop rendering a false "terminal did not become ready" error when sending a message to Claude Code mid-turn (#2001)
|
||||
- [UI] [UI] The "Working…" indicator now stays visible for the whole turn and rotates through a few different labels. (#2006)
|
||||
- [Bug fix] Members page now shows a clear "not available in single-user mode" message instead of a confusing auth error when running without accounts or OIDC. (#2013)
|
||||
- [UI / Bug fix] Global Policies settings page now appears correctly in single-user/header auth mode instead of showing a "no permission" error. (#2017)
|
||||
- [Feature] `intent_gate` policy now prompts for user approval (`ASK`) instead of hard-blocking (`DENY`) tool calls that don't match the session's original intent. (#2024)
|
||||
- [UI / Bug fix] Submitting the Codex goal dialog no longer shifts the footer buttons — the loading spinner replaces the button label in place instead of widening the button (#2032)
|
||||
- [UI / Feature] Add a UI font size setting in Appearance to scale the interface (#2040)
|
||||
- [Bug fix] `/compact` on a `claude-sdk` agent with a pinned Anthropic model no longer 500s — the compaction summarizer was routing bare `claude-*` ids to OpenAI instead of Anthropic. (#2043)
|
||||
- [UI / Feature] Set a custom UI font family in Settings → Appearance (type any installed font; blank = system default). (#2047)
|
||||
- [UI / Bug fix] Fix the Appearance font-size input so you can clear and retype a value instead of it clamping mid-edit (#2053)
|
||||
- [Bug fix] Native Claude sessions no longer get stuck showing "Stop" after switching models in the terminal with `/model` (#2082)
|
||||
- [UI / Feature] The sidebar "Search" now opens the command palette (⌘K) to search sessions by title and chat content, with a keyboard-shortcut hint on hover (#2086)
|
||||
- [UI / Feature] Start a new session directly in an existing git worktree by picking it from the worktree field. (#2088)
|
||||
- [Bug fix] Stop rendering a false "terminal did not become ready" error when sending a message to Claude Code mid-turn with many subagents running (#2089)
|
||||
- [UI / Feature] Generate a unique worktree branch name from the new-session composer. (#2094)
|
||||
- [Feature] The harness capability bench now observes native harness tool calls (Tool (#2096)
|
||||
- [Bug fix] Report missing bubblewrap when building a `web_fetch` researcher instead of failing during spawn (#2097)
|
||||
- [UI / Feature] Sessions started in an existing git worktree now show the branch in the sidebar and can delete the worktree + branch from the session delete dialog. (#2098)
|
||||
- [Bug fix] Fixed OpenShell k8s managed sandboxes failing due to Landlock LSM denying `/home/sandbox`; changed home path to `/sandbox` (#2106)
|
||||
- [UI / Bug fix] The share dialog no longer overflows when a grantee's email is long — the name truncates and the domain stays visible. (#2108)
|
||||
- [Bug fix / Test/CI] Keep claude-native model, permission mode, and effort overrides stable across wrapped Claude Code restarts that preserve the settings sidecar. (#2116)
|
||||
- [Feature] Kubernetes sandbox runner Pods can now schedule on arm64 nodes: set `sandbox.kubernetes.node_selector: {kubernetes.io/arch: arm64}` (amd64 remains the default). (#2123)
|
||||
- [Feature / Test/CI] New official `omnigent-server-kubernetes` image ships the kubernetes sandbox provider SDK — the `sandbox-runners` overlay now works against published images, no custom build needed. (#2124)
|
||||
- [UI / Bug fix] codex-native sessions now show MCP server startup progress in the chat, name servers that failed or were cancelled, and Stop can abort a slow MCP startup (#2128)
|
||||
- [Bug fix] Host-spawned runners now inherit `DATABRICKS_AUTH_STORAGE`, so a runner authenticates against the same Databricks token store as the host (fixes a runner tunnel 401 when the store is selected via env var rather than `~/.databrickscfg`). (#2132)
|
||||
- [UI / Feature] Set the code editor and terminal font size and family from Settings → Appearance (#2135)
|
||||
- [Bug fix] Intelligent routing now correctly routes claude sessions instead of leaving them (#2136)
|
||||
- [Bug fix] Fixed inbox approvals not resuming the gated tool call. (#2142)
|
||||
- [UI / Feature] Pick a color theme (Omnigent, Dracula, GitHub, Catppuccin, or Gruvbox) in Appearance settings, independent of light/dark mode. (#2147)
|
||||
- [UI / Feature] Choose a terminal theme (light or dark) independent of the app theme in Settings, Appearance (#2154)
|
||||
- [UI / Feature] Sessions shared with you now live in a dedicated "Shared with me" sidebar tab (multi-user servers only) (#2156)
|
||||
- [Feature] Tightened `conversations.title` DB column to NOT NULL; untitled conversations are now stored as `''` instead of `NULL`. (#2158)
|
||||
- [Feature / Test/CI] Add a performance-benchmark harness for HTTP user journeys, with a seeded corpus, a SQLite+Postgres backend matrix, and a nightly workflow (`uv run dev/benchmarks/omnigent/run.py`) (#2159)
|
||||
- [Bug fix] Sub-agent hermes sessions no longer wake their parent orchestrator before the turn's final answer is mirrored into the transcript (#2161)
|
||||
- [UI / Feature] Session search now shows a preview of the matching message so you can see why a session matched, with the search term highlighted (#2162)
|
||||
- [Feature / Test/CI] Host runner start logs now include the `conv_*` conversation ID alongside the runner token and log path. (#2170)
|
||||
- [Bug fix] The harness capability bench now reports a real native Policy DENY verdict (#2171)
|
||||
- [UI / Bug fix] Cancel in the add-policy dialog now returns to the policy list instead of closing it (#2183)
|
||||
- [UI / Feature] Users can now edit the policy name in the Add Policy dialog before submitting. (#2196)
|
||||
- [Feature / Test/CI] Add a performance-benchmark harness for HTTP + full-turn user journeys (`uv run dev/benchmarks/omnigent/run.py`), with a seeded corpus and SQLite+Postgres backend matrix (#2202)
|
||||
- [UI / Bug fix] The new-session picker now remembers the host you last picked instead of resetting to the default. (#2218)
|
||||
- [Bug fix] Fixed the Hermes `pre_tool_call` hook double-gating Omnigent relay tools, which parked a (#2220)
|
||||
- [UI / Chore] Redesigned Appearance settings: separate Mode and Color theme sections, app-preview Mode tiles, and a color-theme dropdown. (#2225)
|
||||
- [UI / Feature] Added: auto-routing decisions now show as a collapsible card (model pill, tier, rationale, expandable raw verdict) matching the SmartRoutingCard style (#2246)
|
||||
- [Bug fix] Sessions shared with you no longer appear under "My sessions" when they belong to a project — they stay under "Shared with me" (#2249)
|
||||
- [Test/CI] Doc-sync site PRs are now titled after the documentation change instead of the source PR number. (#2250)
|
||||
- [UI / Bug fix] Stop-session dialog now shows the actual server error instead of a generic message. (#2252)
|
||||
- [UI / Bug fix] Project picker menu rows now align on the left and share a consistent height (#2260)
|
||||
- [Feature] The harness bench can now probe any registered harness by name — including the (#2265)
|
||||
- [UI / Feature] A default base branch can be set in Settings › Git to auto-fill the base when naming a new worktree branch (#2267)
|
||||
- [Feature] `omnigent debug logs` tails runner, server, or CLI diagnostic logs; `--session` scopes runner logs to a specific session across relaunches (#2273)
|
||||
- [Bug fix] `omni run --harness acp:<slug>` now launches a configured ACP agent instead of failing on the colon in the synthesized agent name. (#2280)
|
||||
- [UI / Bug fix] [UI] Fix iOS crash when granting camera or voice-dictation permission in the app (#2282)
|
||||
- [Test/CI] DELETE THIS WHOLE SECTION — CI-only change, not user-facing. (#2288)
|
||||
- [Bug fix / Feature] Fixed: intelligent routing now overrides any model the orchestrator specified in `sys_session_send` when the parent session has the routing toggle on (#2291)
|
||||
- [Bug fix] Fixed a crash when resuming a Claude-native session whose history contained a `TaskOutput` (or similar) result, so resume no longer times out with a terminal-not-ready error. (#2293)
|
||||
- [Test/CI] DELETE THIS WHOLE SECTION — CI-only change, not user-facing. (#2295)
|
||||
- [UI / Bug fix] "Select all" in bulk selection mode now only selects sessions in expanded sidebar sections, not hidden or archived ones. (#2311)
|
||||
- [Bug fix] Fix pi (and opencode policy) losing live web-UI updates on multi-instance deployments by sending their out-of-process callbacks to the same server instance as the runner. (#2328)
|
||||
- [Bug fix] Default policies created via the API (`POST /v1/policies`) now take effect on sessions. (#2333)
|
||||
- [Feature] omnidev dev pods now get their own isolated `config.yaml` (seeded from `~/.omnigent/config.yaml`), so server-config edits while testing in a pod no longer touch your real config (#2360)
|
||||
- [Bug fix] Session search returns matched-content previews faster on large histories. (#2365)
|
||||
- [Feature / Docs / Test/CI] Harness Bench now measures Policy ALLOW and ASK through native CLI policy hooks. (#2370)
|
||||
- [Bug fix] Managed claude-native sessions against an Anthropic-compatible gateway (e.g. LiteLLM or Databricks) now pass through the gateway model and don't stall on Claude Code's custom-API-key menu. (#2371)
|
||||
|
||||
## [v0.4.0] — 2026-07-03
|
||||
|
||||
Highlights and full notes: <https://github.com/omnigent-ai/omnigent/releases/tag/v0.4.0>
|
||||
|
||||
@@ -67,26 +67,6 @@ One command installs Omnigent and everything it needs:
|
||||
curl -fsSL https://raw.githubusercontent.com/omnigent-ai/omnigent/main/scripts/install_oss.sh | sh
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Optional integrations and extras</summary>
|
||||
|
||||
Need an optional integration? Pass one or more extras to the installer:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/omnigent-ai/omnigent/main/scripts/install_oss.sh | sh -s -- --extra databricks
|
||||
curl -fsSL https://raw.githubusercontent.com/omnigent-ai/omnigent/main/scripts/install_oss.sh | sh -s -- --extra modal,e2b
|
||||
```
|
||||
|
||||
Available user-facing extras include:
|
||||
|
||||
- **Model providers:** `databricks`, `bedrock`, `vertex`
|
||||
- **Sandbox providers:** `modal`, `daytona`, `boxlite`, `cwsandbox`, `e2b`,
|
||||
`openshell`, `kubernetes`
|
||||
- **SDK harnesses:** `antigravity`, `copilot`, `cursor`, `agents-sdk`
|
||||
- **Storage and memory:** `s3`, `hindsight`
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Prefer to install manually?</summary>
|
||||
|
||||
@@ -96,12 +76,6 @@ Omnigent needs **Python 3.12+**. Install the `omnigent` package:
|
||||
uv tool install omnigent # or: pip install "omnigent"
|
||||
```
|
||||
|
||||
Manual installs use the same extras syntax, for example:
|
||||
|
||||
```bash
|
||||
uv tool install "omnigent[databricks,modal]"
|
||||
```
|
||||
|
||||
Or with [Homebrew](https://github.com/omnigent-ai/homebrew-tap):
|
||||
|
||||
```bash
|
||||
@@ -199,41 +173,6 @@ mirrors work out of the box; override with `OMNIGENT_INDEX_URL` if needed.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Uninstalling Omnigent</summary>
|
||||
|
||||
Preview the CLI/profile cleanup that would run by default:
|
||||
|
||||
```bash
|
||||
omnigent uninstall
|
||||
```
|
||||
|
||||
Remove the CLI and installer-managed PATH entries while keeping your local
|
||||
history, credentials, and projects:
|
||||
|
||||
```bash
|
||||
omnigent uninstall --yes
|
||||
```
|
||||
|
||||
To also remove Omnigent state under `~/.omnigent`, pass `--purge`; Omnigent
|
||||
backs it up outside the target before deletion. Your `~/omnigent` workspace is
|
||||
kept unless you explicitly add `--purge-workspace`.
|
||||
|
||||
```bash
|
||||
omnigent uninstall --purge --yes
|
||||
```
|
||||
|
||||
If the installed wheel is broken or `omnigent` is not on `PATH`, run the
|
||||
standalone script instead:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/omnigent-ai/omnigent/main/scripts/uninstall_oss.sh | sh
|
||||
```
|
||||
|
||||
Add `--yes` to the standalone script to perform the previewed CLI cleanup.
|
||||
|
||||
</details>
|
||||
|
||||
### 2. Start your first agent
|
||||
|
||||
`omnigent` picks a model with you and starts a session in your terminal. It
|
||||
@@ -524,3 +463,4 @@ Thanks to all of our amazing contributors!
|
||||
<a href="https://github.com/omnigent-ai/omnigent/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=omnigent-ai/omnigent" />
|
||||
</a>
|
||||
|
||||
|
||||
+187
-196
@@ -13,11 +13,6 @@ omnigent ships **three PyPI packages that version-lock together**:
|
||||
pin each other with `==`), so every release builds and publishes **all three at
|
||||
one identical version**.
|
||||
|
||||
Releases are driven by **workflow dispatches, not by hand** (design:
|
||||
`designs/RELEASE-AUTOMATION.md`). Every workflow below is idempotent —
|
||||
re-dispatch with identical inputs after any failure and it converges — and
|
||||
every dispatch requires the **admin or maintain** role on this repo.
|
||||
|
||||
## Where things run
|
||||
|
||||
- **Source of truth** (versions, tags, GitHub Releases): **`omnigent-ai/omnigent`**
|
||||
@@ -25,31 +20,31 @@ every dispatch requires the **admin or maintain** role on this repo.
|
||||
on the public repo).
|
||||
- **Publishing to PyPI**: the central **secure-release repo**
|
||||
**`databricks/secure-public-registry-releases-eng`**, `omnigent` workflow —
|
||||
use whichever account has access to that repo. Publishing runs on hardened runner
|
||||
use the **Databricks EMU account**. Publishing runs on hardened runner
|
||||
groups with **OIDC Trusted Publishing (no stored secrets)** and a **mandatory
|
||||
dependency scan**. This is why we don't publish from `omnigent-ai/omnigent`,
|
||||
and why the pipeline is two dispatches per phase rather than one.
|
||||
dependency scan**. This is why we don't publish from `omnigent-ai/omnigent`.
|
||||
|
||||
> The exact account handles — and how to request publish access — live in the
|
||||
> internal release wiki; this public runbook refers to them only by role.
|
||||
> Substitute your own handles for `<oss-account>` / `<emu-account>` in the
|
||||
> `gh auth switch --user …` commands below.
|
||||
|
||||
The legacy `.github/workflows/release-omnigent.yml` in this repo is a
|
||||
**deprecated manual fallback only** — its tag-push trigger was removed so a tag
|
||||
never double-publishes. Use the secure repo for real releases.
|
||||
|
||||
> The secure `omnigent` workflow is **manual `workflow_dispatch`** — it can't see
|
||||
> this repo's tag pushes. You bump + tag here, then dispatch it with that tag.
|
||||
|
||||
## Versioning model
|
||||
|
||||
- `main` always carries the **next** version with a `.dev0` suffix
|
||||
(e.g. `0.6.0.dev0`) — never a clean released number. This matches
|
||||
(e.g. `0.2.0.dev0`) — never a clean released number. This matches
|
||||
MLflow / Delta / Unity Catalog and keeps every `main` build PEP 440-ordered as
|
||||
"ahead of the last release, not yet the next one".
|
||||
- Releases are cut on **per-minor release branches** (`branch-X.Y`) and tagged
|
||||
there (`vX.Y.Z`, rc tags `vX.Y.ZrcN`); patches (`vX.Y.1`, `vX.Y.2`, …) are
|
||||
cherry-picked onto the same `branch-X.Y`. `main` is never tagged.
|
||||
- Every release ships as an **rc first** (`0.6.0rc1` → … → `0.6.0`). rcs go to
|
||||
**real PyPI** as PEP 440 pre-releases — a default `pip install omnigent`
|
||||
never resolves them, and testers install with exact pins. TestPyPI is no
|
||||
longer part of the standard flow.
|
||||
there (`vX.Y.Z`); patches (`vX.Y.1`, `vX.Y.2`, …) are cherry-picked onto the
|
||||
same `branch-X.Y`. `main` is never tagged.
|
||||
|
||||
## Docs staging
|
||||
|
||||
@@ -61,225 +56,221 @@ branch** on `omnigent-site` instead of `main`:
|
||||
- **`doc-sync.yml`** — drafts prose docs for each merged PR that needs them.
|
||||
- **`sync-openapi-to-site.yml`** — syncs the API reference (`openapi.json`).
|
||||
|
||||
Both derive the branch name from `omnigent/version.py` (`0.6.0.dev0` → `0.6-docs`)
|
||||
Both derive the branch name from `omnigent/version.py` (`0.5.0.dev0` → `0.5-docs`)
|
||||
and create it off site `main` the first time a doc PR lands in the cycle. All docs
|
||||
for the `0.6` line — including patches — accumulate on `0.6-docs`. Each PR still
|
||||
for the `0.5` line — including patches — accumulate on `0.5-docs`. Each PR still
|
||||
gets its own review, but merging one only lands it on the staging branch, not the
|
||||
live site. At finalize time, the whole batch goes live at once (step 4 below).
|
||||
live site.
|
||||
|
||||
At release, publishing the GitHub Release fires `publish-changelog.yml`, which
|
||||
opens the **`0.5-docs → main`** PR (see step 5). Merging that publishes the whole
|
||||
cycle's docs at once. Nothing to create or retarget by hand — the branch name
|
||||
tracks `main`'s version automatically.
|
||||
|
||||
---
|
||||
|
||||
## Standard flow
|
||||
## Release steps (example: `v0.2.0`)
|
||||
|
||||
### rc phase (example: `0.6.0rc1`)
|
||||
### 1. Cut the release branch + tag — `omnigent-ai/omnigent` (OSS account)
|
||||
|
||||
**1. Cut + tag — dispatch `Release` (`release.yml`), OSS account.**
|
||||
Only tag a commit that already has **green CI** — verify `main` is green before
|
||||
branching:
|
||||
|
||||
```bash
|
||||
gh workflow run release.yml --repo omnigent-ai/omnigent \
|
||||
-f version=0.6.0rc1 -f dry_run=false
|
||||
# optional: -f ref=<sha> to cut branch-0.6 from a specific commit (rc1 only);
|
||||
# dry_run defaults to true — run once without -f dry_run to preview the plan.
|
||||
gh auth switch --user <oss-account>
|
||||
git fetch origin
|
||||
gh run list --repo omnigent-ai/omnigent --branch main --status success --limit 1
|
||||
git checkout -b branch-0.2 origin/main
|
||||
```
|
||||
|
||||
What it does (all idempotent):
|
||||
Set the release version in **all three** `pyproject.toml` files — the
|
||||
`version` field **and** the cross-package `==` pins — plus `uv.lock`
|
||||
(`0.2.0.dev0` → `0.2.0`):
|
||||
|
||||
- asserts green CI on the base commit (escape hatch: `-f skip_ci_check=true`,
|
||||
use deliberately — needed for a flaky check, or when the base commit ran no
|
||||
checks at all, e.g. a cherry-pick that only touched `paths-ignore`d files);
|
||||
- creates `branch-0.6` from `ref` (rc1) or reuses the existing branch head
|
||||
(rc2+, final, patches — `ref` is ignored then);
|
||||
- stamps the lockstep version via `scripts/update_versions.py` and regenerates
|
||||
`uv.lock` with a clean public-PyPI resolution — **never hand-edit `uv.lock`
|
||||
or run `uv lock` behind a proxy**; the workflow owns this now;
|
||||
- commits `release: v0.6.0rc1`, tags, and pushes branch + tag with the
|
||||
omnigent-ci App token, which fires the downstream automation:
|
||||
`github-release.yml` (draft GH release, pre-release flagged),
|
||||
`draft-release-notes.yml`, and `oss-publish-images.yml` (Docker);
|
||||
- on the **first** cut of a cycle (rc1), dispatches `bump-version.yml`
|
||||
(post-release) — **review and merge the `main → 0.7.0.dev0` bump PR
|
||||
promptly**, so `doc-sync` keeps staging to the right docs branch.
|
||||
- `pyproject.toml` (`version`, `omnigent-client==`, `omnigent-ui-sdk==`)
|
||||
- `sdks/python-client/pyproject.toml` (`version`, `omnigent==`)
|
||||
- `sdks/ui/pyproject.toml` (`version`, `omnigent-client==`)
|
||||
- `uv.lock` — **hand-edit** the three `version = "…"` lines (omnigent,
|
||||
omnigent-client, omnigent-ui-sdk) and the one cross-pin `specifier = "==…"`
|
||||
(`omnigent-ui-sdk`'s dep on `omnigent-client`). The three packages are
|
||||
**editable workspace members** (`source = { editable = … }`), so uv records
|
||||
**no wheel `hash` entries** for them, and the other two cross-deps appear as
|
||||
`editable = "…"` with no `==` specifier — so only those version/specifier
|
||||
strings change, nothing else (no hashes to touch).
|
||||
**Do not run `uv lock`** locally: it rewrites every registry URL to the
|
||||
internal proxy and that leaks into the lockfile (breaks CI). The published
|
||||
lock must use `https://pypi.org/simple`.
|
||||
|
||||
**2. Publish to PyPI — dispatch the secure repo (EMU account).**
|
||||
Stage exactly the version files (don't `-a`, which would sweep in any stray
|
||||
local edits), then commit, tag, and push **the branch + only this tag**:
|
||||
|
||||
```bash
|
||||
gh auth switch --user <secure-repo-account>
|
||||
git add pyproject.toml sdks/python-client/pyproject.toml sdks/ui/pyproject.toml uv.lock
|
||||
git commit -m "release: v0.2.0"
|
||||
git tag v0.2.0
|
||||
git push -u origin branch-0.2 v0.2.0 # explicit tag, NOT --tags; pushing the tag drafts the GitHub Release (step 5)
|
||||
```
|
||||
|
||||
> Pushing the tag also kicks off the **changelog automation** (see step 5):
|
||||
> `github-release.yml` drafts the Release, then `draft-release-notes.yml` opens a
|
||||
> `CHANGELOG.md` PR and fills the draft with curated notes — both ready by the time
|
||||
> you get to step 5.
|
||||
|
||||
Keep `main` from re-freezing — bump it to the next dev marker and push:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
# set 0.2.0.dev0 -> 0.3.0.dev0 in the 3 pyprojects (+ pins) and uv.lock.
|
||||
# Hand-edit uv.lock here too — same rule, do NOT run `uv lock` (it leaks the proxy URL).
|
||||
git add pyproject.toml sdks/python-client/pyproject.toml sdks/ui/pyproject.toml uv.lock
|
||||
git commit -m "chore: bump main to 0.3.0.dev0"
|
||||
git push
|
||||
```
|
||||
|
||||
### 2. Dry-run the gates — secure repo (EMU account)
|
||||
|
||||
```bash
|
||||
gh auth switch --user <emu-account>
|
||||
gh workflow run omnigent.yml --repo databricks/secure-public-registry-releases-eng \
|
||||
-f ref=v0.6.0rc1 -f destination=pypi -f dry-run=true # gates rehearsal
|
||||
gh workflow run omnigent.yml --repo databricks/secure-public-registry-releases-eng \
|
||||
-f ref=v0.6.0rc1 -f destination=pypi -f dry-run=false # real publish
|
||||
-f ref=v0.2.0 -f destination=test-pypi -f dry-run=true
|
||||
```
|
||||
|
||||
The dry run exercises build + dependency scan + the gates (lockstep
|
||||
version/pins, web-UI-in-wheel, `twine check`, smoke-install) and the OIDC
|
||||
token exchange without uploading. The real run binds the per-package
|
||||
Trusted-Publisher environments (may gate on reviewer approval) and re-verifies
|
||||
that `ref` is exactly the tag and points at the built commit.
|
||||
Runs build + dependency scan + the gates (lockstep version/pins, web-UI-in-wheel,
|
||||
`twine check`, smoke-install) and the OIDC token exchange — **without uploading**.
|
||||
|
||||
**3. Validate from PyPI** (clean venv; exact pins resolve pre-releases):
|
||||
### 3. Publish to TestPyPI + validate
|
||||
|
||||
```bash
|
||||
python -m venv /tmp/omni-rc && /tmp/omni-rc/bin/pip install \
|
||||
--index-url https://pypi.org/simple/ \
|
||||
omnigent==0.6.0rc1 omnigent-client==0.6.0rc1 omnigent-ui-sdk==0.6.0rc1
|
||||
/tmp/omni-rc/bin/omnigent --version # expect 0.6.0rc1
|
||||
gh workflow run omnigent.yml --repo databricks/secure-public-registry-releases-eng \
|
||||
-f ref=v0.2.0 -f destination=test-pypi -f dry-run=false
|
||||
```
|
||||
|
||||
The rc's GitHub draft stays **unpublished** — rc drafts are never published.
|
||||
Need another candidate? Repeat with `0.6.0rc2` (fixes land on `branch-0.6`
|
||||
first, via cherry-pick PRs or direct pushes; CI runs on `branch-*` pushes).
|
||||
Validate in a clean venv. **Don't** use `--extra-index-url` with TestPyPI: pip
|
||||
resolves each name across *both* indexes and picks the highest version, so anyone
|
||||
squatting `omnigent` / `omnigent-client` / `omnigent-ui-sdk` on real PyPI at a
|
||||
higher version wins the resolution (dependency confusion). Instead, take **deps
|
||||
from real PyPI only** and the **candidates from TestPyPI only**, exact-pinned with
|
||||
`--no-deps`:
|
||||
|
||||
### Final phase (example: `0.6.0`)
|
||||
```bash
|
||||
python -m venv /tmp/omni-rc
|
||||
# 1) seed the dependency closure from REAL PyPI (the last released omnigent):
|
||||
/tmp/omni-rc/bin/pip install --index-url https://pypi.org/simple/ omnigent
|
||||
# 2) overlay the candidates from TestPyPI ONLY, exact-pinned, no deps:
|
||||
/tmp/omni-rc/bin/pip install --index-url https://test.pypi.org/simple/ --no-deps \
|
||||
omnigent==0.2.0 omnigent-client==0.2.0 omnigent-ui-sdk==0.2.0
|
||||
/tmp/omni-rc/bin/omnigent --version # expect 0.2.0
|
||||
```
|
||||
|
||||
1. **Cut + tag**: `gh workflow run release.yml -f version=0.6.0 -f dry_run=false`
|
||||
— same as above; builds from the `branch-0.6` head.
|
||||
2. **Publish to PyPI**: same secure-repo dispatches on `ref=v0.6.0`.
|
||||
3. **Curate**: merge the `CHANGELOG.md` PR that `draft-release-notes.yml`
|
||||
opened, and review/trim the curated notes in the `v0.6.0` draft on the
|
||||
Releases page — whatever you leave becomes the website post.
|
||||
4. **Finalize — dispatch `Finalize release` (`finalize-release.yml`)**:
|
||||
> If this release **adds a new runtime dependency** the previous release didn't
|
||||
> have, install it explicitly from real PyPI first
|
||||
> (`/tmp/omni-rc/bin/pip install --index-url https://pypi.org/simple/ <dep>`) —
|
||||
> never let a `--no-deps` TestPyPI install pull third-party deps from TestPyPI.
|
||||
|
||||
```bash
|
||||
gh workflow run finalize-release.yml --repo omnigent-ai/omnigent -f tag=v0.6.0
|
||||
```
|
||||
### 4. Publish to PyPI (prod)
|
||||
|
||||
It verifies PyPI serves all three packages, the CHANGELOG PR isn't open,
|
||||
and the **docs sweep**: no open PRs against `0.6-docs` on `omnigent-site`
|
||||
(it lists any stragglers — get them reviewed and merged/closed, then
|
||||
re-dispatch). Then it pauses on the **`publish-release` environment**;
|
||||
approving it attests "I reviewed the draft notes". It publishes the release
|
||||
as **Latest**, which fires:
|
||||
- `publish-changelog.yml` → the site **release-post PR** and the
|
||||
**`0.6-docs → main` docs-publish PR** — review and merge both;
|
||||
- `update-homebrew.yml` → the **homebrew-tap bump PR** (new sdist pin +
|
||||
regenerated resources; test-bot builds the bottles on it) — review the
|
||||
resource diff, then apply the **`pr-pull`** label to bottle + merge.
|
||||
Requires **admin/maintain** on the secure repo (if you hit a 403, request access
|
||||
via the secure-release owning team / internal release wiki before proceeding);
|
||||
binds the per-package `pypi-omnigent`, `pypi-omnigent-client`,
|
||||
`pypi-omnigent-ui-sdk` Trusted-Publisher environments (may gate on reviewer
|
||||
approval). The prod path also re-verifies that
|
||||
`ref` is exactly the `vX.Y.Z` tag and that the tag points at the built commit.
|
||||
|
||||
### Patch release (example: `0.6.1`)
|
||||
```bash
|
||||
gh workflow run omnigent.yml --repo databricks/secure-public-registry-releases-eng \
|
||||
-f ref=v0.2.0 -f destination=pypi -f dry-run=false
|
||||
|
||||
Cherry-pick the fixes onto `branch-0.6` (CI runs on the push), then run the
|
||||
same flow with `version=0.6.1` — an rc first if the patch warrants one. `main`
|
||||
does not change for a patch, and a patch never needs a new branch.
|
||||
uv tool install omnigent==0.2.0 # final sanity from real PyPI
|
||||
```
|
||||
|
||||
> Note: the dispatch's `-f ref=v0.2.0` is the **omnigent source ref**; it is
|
||||
> distinct from `gh workflow run --ref`, which selects the branch the *workflow
|
||||
> definition* runs from (the secure repo's default).
|
||||
|
||||
### 5. Publish the GitHub Release — `omnigent-ai/omnigent` (OSS account)
|
||||
|
||||
Pushing the `v0.2.0` tag (step 1) set the **changelog automation** in motion —
|
||||
two workflows have already done the prep for you:
|
||||
|
||||
- `github-release.yml` created a **draft** release.
|
||||
- `draft-release-notes.yml` (fires right after) then:
|
||||
1. opened a **`CHANGELOG.md` PR to `main`** — the granular, feature-level log,
|
||||
harvested mechanically from each merged PR's `## Changelog` section; and
|
||||
2. **filled the draft's body** with concise, curated notes (Major new features /
|
||||
Breaking changes / Bug fixes — user-facing only), synthesized by an agent from
|
||||
the merged PRs, with the original auto-notes tucked into a collapsed
|
||||
`<details>` for reference. Security and CI/internal fixes are deliberately left
|
||||
out of the highlights.
|
||||
|
||||
Now:
|
||||
|
||||
1. **Merge the `CHANGELOG.md` PR** as part of cutting the release, so the draft's
|
||||
`Full Changelog` link (which points at `CHANGELOG.md` on `main`) resolves.
|
||||
2. Open <https://github.com/omnigent-ai/omnigent/releases>, find the `v0.2.0`
|
||||
draft, and **review/trim the curated notes** — they're a strong starting point,
|
||||
not the final word. Lead with user-facing highlights; call out breaking changes.
|
||||
Whatever you leave here becomes the website post, so curate it well.
|
||||
3. **Publish the release** (ideally only after the prod PyPI publish in step 4 has
|
||||
succeeded, so you never advertise a version that isn't installable).
|
||||
|
||||
Publishing a **final** release fires `.github/workflows/publish-changelog.yml`,
|
||||
which opens **two** PRs to review and merge (pre-releases are skipped):
|
||||
|
||||
- **`omnigent-site` `/releases/<version>`** — a per-version post mirroring the
|
||||
notes you just curated (PR refs and angle/brace characters are made MDX-safe for
|
||||
you). Targets `main`.
|
||||
- **`omnigent-site` `X.Y-docs → main`** — publishes the docs staged this cycle
|
||||
(see [Docs staging](#docs-staging) below). Skipped if that branch doesn't exist
|
||||
or has nothing beyond `main`. Review the batch and merge to take the version's
|
||||
docs live.
|
||||
|
||||
To re-run either half for an already-cut tag: dispatch `draft-release-notes.yml`
|
||||
with the `tag` (re-opens the CHANGELOG PR; it leaves the notes alone once the
|
||||
release is published), or `publish-changelog.yml` with the `tag` (re-opens the
|
||||
site post PR).
|
||||
|
||||
If the draft wasn't created (e.g. the workflow was disabled), do it manually:
|
||||
|
||||
```bash
|
||||
gh auth switch --user <oss-account>
|
||||
gh release create v0.2.0 --repo omnigent-ai/omnigent \
|
||||
--draft --verify-tag --generate-notes --title "v0.2.0"
|
||||
# review/edit, then publish from the Releases page (or `gh release edit v0.2.0 --draft=false`)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## One-time setup (repo admin)
|
||||
## Patch release (e.g. `v0.2.1`)
|
||||
|
||||
- **`publish-release` environment** on `omnigent-ai/omnigent` with required
|
||||
reviewers = the release managers. Without it the finalize publish job runs
|
||||
ungated.
|
||||
- **omnigent-ci App** installed on `omnigent-ai/homebrew-tap` (it already
|
||||
covers `omnigent` and `omnigent-site`).
|
||||
- **Tag ruleset** (recommended): restrict `v[0-9]*` create/update/delete to
|
||||
the omnigent-ci App + admins, so no write-access account can start the
|
||||
tag-push automation by hand.
|
||||
Cherry-pick the fix onto the existing `branch-0.2`, then:
|
||||
|
||||
1. Confirm CI is green on `branch-0.2` after the cherry-pick
|
||||
(`gh run list --repo omnigent-ai/omnigent --branch branch-0.2 --status success --limit 1`).
|
||||
2. Bump the three versions/pins + `uv.lock` to `0.2.1` (same hand-edit rules as above).
|
||||
3. Stage explicitly, commit, and tag **on `branch-0.2`**:
|
||||
`git add <version files> && git commit -m "release: v0.2.1" && git tag v0.2.1 && git push origin branch-0.2 v0.2.1`.
|
||||
4. Repeat steps 2–5.
|
||||
|
||||
`main` does **not** change for a patch, and a patch never needs a new
|
||||
`branch-0.Y` — patches always ship from the existing minor branch.
|
||||
|
||||
---
|
||||
|
||||
## If a publish goes wrong (recovery)
|
||||
|
||||
**PyPI releases can't be deleted, only _yanked_**, and a version number once used
|
||||
can never be reused. So:
|
||||
|
||||
- **Any workflow failed mid-run:** fix the cause and **re-dispatch with the
|
||||
same inputs** — every step converges (branch exists → reused; version
|
||||
stamped → no new commit; tag at the converged commit → no-op) or fails
|
||||
loudly (tag elsewhere) rather than duplicating work.
|
||||
- **Wrong commit tagged, nothing published yet:** delete the tag and draft
|
||||
(`gh release delete vX.Y.Z`, `git push origin :refs/tags/vX.Y.Z`), then
|
||||
re-dispatch `release.yml`.
|
||||
- **rc is bad:** just cut the next rc — rcs are cheap and invisible to
|
||||
default installs.
|
||||
- **TestPyPI failed / candidate is bad:** bump to the next number (don't reuse the
|
||||
version) and re-run — TestPyPI is disposable.
|
||||
- **Prod publish partially succeeded** (e.g. two of three packages uploaded):
|
||||
**yank** the published version(s) on PyPI (each affected project → *Manage* →
|
||||
*Releases* → *Yank*) so installs don't resolve a half-published set, then cut
|
||||
the next version with the fix. Don't try to overwrite — Trusted Publishing /
|
||||
`twine` rejects re-uploading an existing version.
|
||||
- Publishing uses **OIDC Trusted Publishing (no stored secrets)**, so a failed
|
||||
run leaks nothing — fix forward to the next version.
|
||||
|
||||
---
|
||||
|
||||
## Rehearsing the pipeline (throwaway release to TestPyPI)
|
||||
|
||||
To exercise the whole flow end to end without touching users, release a
|
||||
deliberately **below-latest** rc (e.g. `0.0.1rc1`) and publish it to
|
||||
**TestPyPI**. A below-latest rc is inert everywhere that matters: the GitHub
|
||||
draft stays unpublished, Docker publishes only the immutable `:v0.0.1rc1`
|
||||
image tag (`:latest` / `:latest-rc` only move for the highest version), the
|
||||
notes/site/homebrew workflows ignore rc tags, `bump-main` skips itself (the
|
||||
version sorts below main's), and nothing on TestPyPI is ever resolved by a
|
||||
default `pip install`.
|
||||
|
||||
1. **Plan (read-only)** — dry run is the default:
|
||||
|
||||
```bash
|
||||
gh workflow run release.yml --repo omnigent-ai/omnigent -f version=0.0.1rc1
|
||||
```
|
||||
|
||||
2. **Execute**: re-run with `-f dry_run=false`. Expect `branch-0.0` + tag
|
||||
`v0.0.1rc1` pushed, the tag firing the draft-release and image workflows,
|
||||
and CI running on the branch push. If the CI gate rejects main's head
|
||||
(failing or still-pending checks), that's the gate working — wait, or
|
||||
re-dispatch with `-f ref=<green sha>` / `-f skip_ci_check=true`.
|
||||
Cancelled (superseded) runs only warn.
|
||||
3. **Idempotency**: dispatch the exact same command again — it must no-op
|
||||
("already at the converged release commit").
|
||||
4. **Secure-repo publish**, pointed at TestPyPI instead of PyPI:
|
||||
|
||||
```bash
|
||||
gh workflow run omnigent.yml --repo databricks/secure-public-registry-releases-eng \
|
||||
-f ref=v0.0.1rc1 -f destination=test-pypi -f dry-run=true # gates only
|
||||
gh workflow run omnigent.yml --repo databricks/secure-public-registry-releases-eng \
|
||||
-f ref=v0.0.1rc1 -f destination=test-pypi -f dry-run=false # real TestPyPI upload
|
||||
```
|
||||
|
||||
test-pypi runs skip the tag/version prod gate and the post-publish
|
||||
`validate` job (TestPyPI lacks the dependency closure) and bind the shared
|
||||
`test-pypi` environment. If an upload leg fails with an invalid-publisher
|
||||
error, add the missing TestPyPI Trusted Publisher for that package and
|
||||
re-dispatch — already-uploaded legs are skipped.
|
||||
5. **Publish idempotency**: re-dispatch step 4's second command — all three
|
||||
packages must skip as already published.
|
||||
6. **Finalize gates (no side effects)**:
|
||||
`gh workflow run finalize-release.yml -f tag=v0.0.1rc1` must fail fast
|
||||
("not a final tag"), and `-f tag=v0.5.1` (any already-published release)
|
||||
must no-op as already published.
|
||||
|
||||
Cleanup — delete everything the rehearsal minted:
|
||||
|
||||
```bash
|
||||
gh release delete v0.0.1rc1 --repo omnigent-ai/omnigent --cleanup-tag --yes
|
||||
gh api -X DELETE repos/omnigent-ai/omnigent/git/refs/heads/branch-0.0
|
||||
```
|
||||
|
||||
Optionally delete the `v0.0.1rc1` image versions from GHCR. TestPyPI needs no
|
||||
cleanup — the version number is burned there only, which is what TestPyPI is
|
||||
for.
|
||||
|
||||
---
|
||||
|
||||
## Break-glass appendix (manual fallback)
|
||||
|
||||
If the workflows are unavailable, the flow can be driven by hand — but keep two
|
||||
rules even then:
|
||||
|
||||
1. **Never hand-edit `uv.lock` and never run `uv lock` behind a proxy.** Use
|
||||
`bump-version.yml` (mode `pre-release`, `base_branch=branch-X.Y`) to
|
||||
produce the bump as a PR with a cleanly regenerated lockfile, and merge it.
|
||||
2. **Push tags from an account, not automation you improvised** — the tag push
|
||||
must fire `github-release.yml` et al., which a `GITHUB_TOKEN`-authored push
|
||||
would not.
|
||||
|
||||
```bash
|
||||
gh auth switch --user <oss-account>
|
||||
git fetch origin && git checkout -b branch-0.6 origin/main # rc1 only
|
||||
gh workflow run bump-version.yml -f mode=pre-release -f new_version=0.6.0rc1 \
|
||||
-f base_branch=branch-0.6 # then merge the PR
|
||||
git fetch origin && git checkout branch-0.6 && git pull
|
||||
git tag v0.6.0rc1 && git push origin branch-0.6 v0.6.0rc1 # explicit tag, NOT --tags
|
||||
```
|
||||
|
||||
Then continue from step 2 of the standard flow (secure-repo dispatches). If the
|
||||
GH draft wasn't created, `gh release create vX.Y.Z --draft --verify-tag
|
||||
--title vX.Y.Z` recreates it. To re-run the notes/site halves for an existing
|
||||
tag, dispatch `draft-release-notes.yml` or `publish-changelog.yml` with the
|
||||
`tag` input; for the tap, dispatch `update-homebrew.yml`.
|
||||
*Releases* → *Yank*) so installs don't resolve a half-published set, then cut the
|
||||
next patch with the fix. Don't try to overwrite — Trusted Publishing / `twine`
|
||||
rejects re-uploading an existing version.
|
||||
- **GitHub Release** for a version you abandoned:
|
||||
`gh release delete vX.Y.Z --repo omnigent-ai/omnigent`, and drop the tag if it
|
||||
shouldn't exist (`git push origin :refs/tags/vX.Y.Z`); re-tag only the corrected
|
||||
commit.
|
||||
- Publishing uses **OIDC Trusted Publishing (no stored secrets)**, so a failed run
|
||||
leaks nothing — just fix forward to the next version.
|
||||
|
||||
+14
-61
@@ -10,9 +10,10 @@ running Omnigent hosts, two ways:
|
||||
a session is created with `"host_type": "managed"` and terminates it
|
||||
when the session is deleted.
|
||||
|
||||
Sandboxes boot from the official prebaked host image. The Islo launcher
|
||||
uses the Islo Python SDK, installed with the optional `omnigent[islo]`
|
||||
extra, and authenticates with an API key.
|
||||
Sandboxes boot from the official prebaked host image, so startup is
|
||||
seconds. Unlike Modal and Daytona, the Islo launcher talks to the Islo
|
||||
HTTP API directly through `httpx` (already an Omnigent dependency), so
|
||||
there is **no provider SDK extra to install** — just an API key.
|
||||
|
||||
What makes Islo different from the other providers, and shapes the rest
|
||||
of this guide:
|
||||
@@ -30,13 +31,11 @@ of this guide:
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install Omnigent with the Islo extra, install the
|
||||
[Islo CLI](https://docs.islo.dev), and create an API key. Make the key
|
||||
available where the launcher runs — your shell for the CLI flow, the
|
||||
**server** process for managed sandboxes:
|
||||
Install the [Islo CLI](https://docs.islo.dev) and create an API key, then
|
||||
make it available where the launcher runs — your shell for the CLI flow,
|
||||
the **server** process for managed sandboxes:
|
||||
|
||||
```bash
|
||||
pip install 'omnigent[islo]' # or: uv tool install 'omnigent[islo]'
|
||||
curl -fsSL https://islo.dev/install.sh | sh # install the islo CLI
|
||||
islo login # browser OAuth (one-time)
|
||||
islo api-key create omnigent --show # prints an islo_key_… value
|
||||
@@ -45,9 +44,9 @@ export ISLO_API_KEY=islo_key_…
|
||||
# export ISLO_BASE_URL=https://api.islo.dev
|
||||
```
|
||||
|
||||
`ISLO_API_KEY` is exchanged by the SDK for short-lived session tokens and
|
||||
refreshed automatically. The key is the only required runtime credential;
|
||||
no `~/.config` file is needed where the launcher runs.
|
||||
`ISLO_API_KEY` is exchanged for a short-lived session token at
|
||||
`POST /auth/token`; the token is cached until shortly before expiry. The
|
||||
key is the only required credential — no SDK, no `~/.config` file.
|
||||
|
||||
> [!NOTE]
|
||||
> **Islo cannot forward a local callback port into the sandbox.** The
|
||||
@@ -96,7 +95,7 @@ pulls the image, not Omnigent).
|
||||
Provision a sandbox and ship your local checkout into it:
|
||||
|
||||
```bash
|
||||
omnigent sandbox create --provider islo --server https://your-host
|
||||
omnigent sandbox create --provider islo
|
||||
```
|
||||
|
||||
This pulls the host image, builds wheels from your local checkout, and
|
||||
@@ -122,31 +121,6 @@ delete the old one (Islo sandboxes have no lifetime cap, so an abandoned
|
||||
sandbox keeps billing until removed via `islo rm <id>` or the
|
||||
[dashboard](https://app.islo.dev)).
|
||||
|
||||
### Live smoke checklist
|
||||
|
||||
Use this checklist before opening a provider-change PR, or when validating
|
||||
a new Islo account/key. It assumes your Omnigent server is reachable from
|
||||
Islo's cloud at `https://your-host` (for local testing, expose it with a
|
||||
tunnel and use the public URL).
|
||||
|
||||
```bash
|
||||
islo login
|
||||
islo api-key create omnigent-smoke --show
|
||||
export ISLO_API_KEY=islo_key_...
|
||||
omnigent sandbox create --provider islo --server https://your-host
|
||||
omnigent sandbox connect --provider islo \
|
||||
--sandbox-id <id-printed-by-create> \
|
||||
--server https://your-host
|
||||
islo ls
|
||||
islo rm <id-printed-by-create>
|
||||
```
|
||||
|
||||
Expected result: `create` provisions the sandbox and ships wheels,
|
||||
`connect` registers the host with the Omnigent server, `islo ls` shows the
|
||||
sandbox while it exists, and `islo rm` deletes it. If `connect` cannot
|
||||
reach the server, first verify the `--server` URL from a machine outside
|
||||
your laptop network.
|
||||
|
||||
To inject LLM/git credentials into a CLI-launched sandbox, set
|
||||
`OMNIGENT_ISLO_SANDBOX_ENV` in your shell to a comma-separated list of
|
||||
variable names (e.g. `ANTHROPIC_API_KEY,GIT_TOKEN`) before running
|
||||
@@ -210,12 +184,6 @@ Each managed sandbox authenticates back with a server-minted, per-launch
|
||||
token (7-day TTL — see [Lifecycle](#lifecycle-notes)); no user
|
||||
credentials enter the sandbox for the server connection.
|
||||
|
||||
Managed Islo sandboxes pause after 15 idle minutes by default. When a new
|
||||
message arrives for a session bound to an offline Islo-managed host,
|
||||
Omnigent resumes the same sandbox id, mints a fresh launch token, and
|
||||
restarts `omnigent host` against the existing workspace. Deleting the
|
||||
session still deletes the sandbox.
|
||||
|
||||
### Managed hosts and server auth
|
||||
|
||||
How the dial-back authenticates depends on how the **server** does auth,
|
||||
@@ -266,12 +234,11 @@ sandbox:
|
||||
env: [OPENAI_API_KEY, GIT_TOKEN] # copy from server env
|
||||
base_url: https://api.islo.dev # non-default API endpoint
|
||||
gateway_profile: default # Islo gateway for egress + credential injection
|
||||
snapshot_name: omnigent-host-snapshot # optional named Islo snapshot
|
||||
snapshot_name: warm-host # boot from a prebaked snapshot
|
||||
workdir: /root/workspace # sandbox working directory
|
||||
vcpus: 2
|
||||
memory_mb: 4096
|
||||
disk_gb: 20
|
||||
idle_pause_after_s: 900 # null disables idle pause
|
||||
```
|
||||
|
||||
## Model credentials (LLM keys)
|
||||
@@ -474,21 +441,8 @@ guide](../modal/README.md#git-credentials-private-repositories).
|
||||
yourself (`islo rm <id>`).
|
||||
- **Resources.** Sandboxes default to 2 vCPUs and 4 GiB of memory;
|
||||
override per managed launch with `vcpus` / `memory_mb` / `disk_gb`.
|
||||
- **Snapshots.** Set `sandbox.islo.snapshot_name` to boot from a named
|
||||
Islo snapshot instead of the configured image.
|
||||
- **Idle pause.** Server-managed Islo sandboxes pause after 15 idle
|
||||
minutes by default (`idle_pause_after_s: 900`). Set
|
||||
`idle_pause_after_s: null` to opt out and manage sandbox lifetime
|
||||
yourself. The policy is set when the sandbox is created, so changing it
|
||||
affects new managed sandboxes, not existing ones. This uses Islo's
|
||||
pause/resume lifecycle because the workspace survives and Omnigent can
|
||||
wake it on the next message. Daytona's 15-minute provider default is
|
||||
disabled in Omnigent instead, because Daytona auto-stop would otherwise
|
||||
kill the host between turns.
|
||||
- **Managed resume.** Paused or stopped server-managed Islo sandboxes can
|
||||
resume in place under the same sandbox id and workspace. Session delete
|
||||
still deletes the sandbox. This resume path is what wakes a 15-minute
|
||||
idle-paused host on the next message.
|
||||
- **Warm starts.** Set `sandbox.islo.snapshot_name` to boot from a
|
||||
prebaked Islo snapshot instead of a cold image pull.
|
||||
- **Provider-side lifecycle** (list / status / delete / stop) — use the
|
||||
`islo` CLI (`islo ls`, `islo rm <id>`) or the
|
||||
[dashboard](https://app.islo.dev) directly.
|
||||
@@ -531,7 +485,6 @@ free credits. Rates: [islo.dev](https://islo.dev).
|
||||
|---|---|---|
|
||||
| `ISLO_API_KEY` | CLI machine / server | Islo API credentials (required) |
|
||||
| `ISLO_BASE_URL` | CLI machine / server | Non-default Islo API endpoint (default `https://api.islo.dev`) |
|
||||
| `ISLO_COMPUTE_URL` | CLI machine / server | Non-default Islo compute endpoint (SDK default is production compute) |
|
||||
| `OMNIGENT_ISLO_HOST_IMAGE` | CLI machine / server | Override the host image ref (`sandbox.islo.image` takes precedence for managed) |
|
||||
| `OMNIGENT_ISLO_SANDBOX_ENV` | CLI machine / server | Comma-separated launcher-side env var names to inject (`sandbox.islo.env` takes precedence for managed) |
|
||||
| `OMNIGENT_RUNNER_ENV_PASSTHROUGH` | inside the sandbox (injected) | Extra env var names the host forwards to runners |
|
||||
|
||||
@@ -1,475 +0,0 @@
|
||||
# Deterministic release pipeline
|
||||
|
||||
Status: accepted 2026-07-14; implemented in this repo 2026-07-15 (release.yml,
|
||||
finalize-release.yml, update-homebrew.yml, bump-version App token, branch-CI
|
||||
triggers, lockstep CI check, RELEASING.md rewrite). Secure-repo restructure and
|
||||
the tag ruleset are follow-ups. Owner: @dhruv0811.
|
||||
|
||||
Today a release is an LLM agent (or human) walking `RELEASING.md` step by step:
|
||||
~15 CLI commands across two GitHub accounts, two repos, a hand-edited lockfile,
|
||||
and judgment calls interleaved with mechanical steps. Every step of that runbook
|
||||
is either already a workflow or trivially expressible as one. This doc proposes
|
||||
collapsing the mechanical 90% into **two `workflow_dispatch` runs per release
|
||||
phase** (rc, then final), parameterized by `version` + `ref`, while keeping every
|
||||
human-judgment point (publish approval, notes curation, docs review) as an
|
||||
explicit gate rather than an implicit runbook step.
|
||||
|
||||
## What exists today (verified against the repo, 2026-07-14)
|
||||
|
||||
The pipeline is already more automated than RELEASING.md's manual framing
|
||||
suggests. Per release step:
|
||||
|
||||
| Step | Mechanism today | Deterministic? |
|
||||
| --- | --- | --- |
|
||||
| Cut `branch-X.Y` from green main/SHA | human CLI | ❌ manual |
|
||||
| Lockstep bump (3 `pyproject.toml` + `omnigent/version.py` + `uv.lock`) | `scripts/update_versions.py` (+ `bump-version.yml` wrapper) | ✅ exists, but human-invoked; RELEASING.md still says "hand-edit `uv.lock`" (CI `uv lock` has no proxy problem) |
|
||||
| Tag `vX.Y.Z[rcN]` + push | human CLI | ❌ manual |
|
||||
| Bump main to next `.dev0` | human CLI (or `bump-version.yml` post-release) | 🟡 semi |
|
||||
| Draft GH release (prerelease flag for rc, rerun-safe) | `github-release.yml` on tag push | ✅ |
|
||||
| CHANGELOG PR + LLM-curated draft notes | `draft-release-notes.yml` via `workflow_run` (final tags only) | ✅ |
|
||||
| Secure-repo gates + PyPI publish | manual `gh workflow run omnigent.yml` ×2–3 (dry-run, [test-pypi], pypi) in `databricks/secure-public-registry-releases-eng` | ❌ manual dispatches |
|
||||
| Post-publish validation (clean venv install + `--version`) | human CLI recipe | ❌ manual |
|
||||
| Publish GH release as Latest | human UI click | ❌ manual (and API publish does **not** set `make_latest` unless told to) |
|
||||
| Site release post + `X.Y-docs → main` PR | `publish-changelog.yml` on `release: published` | ✅ |
|
||||
| Sweep open doc PRs against `X.Y-docs` before docs go live | nobody | ❌ missing |
|
||||
| Docker images (`:vX.Y.Z`, `:latest`, `:latest-rc`) | `oss-publish-images.yml` on tag push, PEP 440-ordered moving tags | ✅ |
|
||||
| Homebrew formula bump (`omnigent-ai/homebrew-tap`) | nobody — tap frozen at **0.2.0** while PyPI is at 0.5.1 | ❌ missing |
|
||||
|
||||
Internal precedent: the VS Code extension track already ships the exact target
|
||||
shape — `vscode-release-pr.yml` (`version`, `dry_run` → bump PR) +
|
||||
`vscode-extension-release.yml` (`version`, `dry_run` → build + draft release).
|
||||
This proposal is the same pattern applied to the Python release.
|
||||
|
||||
Actual release history confirms the rc-then-final model this automates:
|
||||
`v0.4.0rc1 → rc2 → v0.4.0`, `v0.5.0rc1 → rc2 → v0.5.0 → v0.5.1` (patch), with rc
|
||||
GitHub releases left as prerelease drafts.
|
||||
|
||||
## Target model
|
||||
|
||||
Per phase (rc or final), the human does:
|
||||
|
||||
```
|
||||
rc: dispatch release.yml (version=0.6.0rc1) # cut/bump/tag — one run
|
||||
dispatch secure omnigent.yml (ref=v0.6.0rc1) # gates → [approve] → publish → validate
|
||||
final: dispatch release.yml (version=0.6.0)
|
||||
dispatch secure omnigent.yml (ref=v0.6.0)
|
||||
…curate the draft notes, merge the CHANGELOG PR…
|
||||
dispatch finalize-release.yml (tag=v0.6.0) # checks → [approve] → publish-as-Latest
|
||||
…merge the two site PRs it triggers…
|
||||
…review the auto-opened homebrew-tap bump PR, apply the pr-pull label…
|
||||
```
|
||||
|
||||
Two runs per phase (finalize is the third, final-only, and exists to *gate*
|
||||
judgment, not do work). Everything inside a run is deterministic, idempotent,
|
||||
and re-dispatchable after a failure with the same inputs.
|
||||
|
||||
Deliberately **not** one run: the secure-repo dispatch stays separate because it
|
||||
crosses the org/account boundary that repo exists to enforce. Auto-dispatching
|
||||
it from the public repo would require storing a Databricks-account PAT in
|
||||
`omnigent-ai/omnigent` — weakening the isolation for the sake of one saved
|
||||
click. Rejected.
|
||||
|
||||
## Workflow 1 — `release.yml` (new, omnigent-ai/omnigent)
|
||||
|
||||
`workflow_dispatch` inputs:
|
||||
|
||||
- `version` — `0.6.0rc1` | `0.6.0` | `0.6.1` (no leading `v`; `.dev` rejected)
|
||||
- `ref` — default `main`; branch/tag/SHA to cut from. **Only consulted when
|
||||
`branch-X.Y` does not exist yet** (i.e. at rc1). Later rcs, the final, and
|
||||
patches always build from the existing `branch-X.Y` head; passing a `ref` that
|
||||
disagrees with it fails loudly instead of silently retargeting.
|
||||
- `dry_run` — default `true` (repo convention, matches the vscode workflows):
|
||||
run the whole plan, print it, push nothing.
|
||||
|
||||
Jobs:
|
||||
|
||||
1. **plan** (always): validate version shape (reuse `bump-version.yml`'s PEP 440
|
||||
regex minus `.dev`); derive `branch-X.Y` + `vX.Y.Z[rcN]`; resolve the base SHA
|
||||
(existing branch head, else `ref`); assert the tag doesn't exist (or already
|
||||
points at the fully-converged state → declare no-op); assert the resolved
|
||||
SHA's check suites are green (not just "some run on main succeeded"); for a
|
||||
final, warn if no `vX.Y.*rc*` tag exists on the branch. Write the plan to the
|
||||
step summary.
|
||||
2. **execute** (`dry_run == false`): mint the omnigent-ci App token; create
|
||||
`branch-X.Y` at the base SHA if missing; `update_versions.py pre-release
|
||||
--new-version $VERSION`; `uv lock` (runner resolves against real PyPI — this
|
||||
*retires the hand-edit-uv.lock ritual entirely*); `update_versions.py check`;
|
||||
commit `release: vX.Y.Z` (skip when already stamped); tag; push branch + tag
|
||||
**with the App token**. Pushing with the App token (not `GITHUB_TOKEN`) is
|
||||
load-bearing: `GITHUB_TOKEN`-pushed tags do not trigger workflows, and the
|
||||
whole downstream chain (`github-release.yml` → `draft-release-notes.yml`,
|
||||
`oss-publish-images.yml`) hangs off that tag push.
|
||||
3. **bump-main** (only when the branch was created in this run, i.e. rc1):
|
||||
`gh workflow run bump-version.yml -f mode=post-release …` — opens the
|
||||
`main → next .dev0` PR immediately at branch cut, exactly as RELEASING.md
|
||||
step 1 prescribes ("keep main from re-freezing"). Merging it promptly also
|
||||
matters for docs: `doc-sync.yml` derives the `X.Y-docs` staging branch from
|
||||
main's version. **Decided:** `bump-version.yml` switches its PR-creation
|
||||
push to the omnigent-ci App token (falling back to `GITHUB_TOKEN` where the
|
||||
App vars are absent, e.g. forks) so CI runs on bump PRs — retiring the
|
||||
documented "push an empty commit to kick CI" workaround.
|
||||
4. **summary**: print the exact secure-repo dispatch command for this tag.
|
||||
|
||||
Idempotency contract: branch exists → reuse; version already stamped → no
|
||||
commit; tag exists at the converged commit → no-op; tag exists elsewhere →
|
||||
fail. A half-failed run is always safe to re-dispatch verbatim.
|
||||
|
||||
Security posture: this executes repo scripts from a maintainer-chosen,
|
||||
CI-green commit under `workflow_dispatch` — the same trust level as the
|
||||
existing `bump-version.yml`. The no-code-exec guarantee of `github-release.yml`
|
||||
(which is *tag-triggered*, attacker-influenceable) is unaffected.
|
||||
|
||||
## Workflow 2 — secure repo `omnigent.yml` restructure
|
||||
|
||||
Today: 2–3 dispatches (dry-run=true, optional test-pypi, then pypi) with manual
|
||||
validation between. Proposal — same file, split into three chained jobs so one
|
||||
dispatch covers the user flow "dry-run, then real publish, then validate":
|
||||
|
||||
1. **gates** (always): build all three distributions once; dependency scan;
|
||||
lockstep/pin verification; web-UI-in-wheel; `twine check`; smoke-install.
|
||||
Upload the built artifacts as run artifacts. This *is* the dry run.
|
||||
2. **publish**: `needs: gates`, bound to the protected Trusted-Publisher
|
||||
environments (required reviewer = the human authorization click). Downloads
|
||||
the **same artifacts** — never rebuilds, so what was scanned is what ships.
|
||||
Before each upload, probe `https://pypi.org/pypi/<pkg>/<ver>/json` and skip
|
||||
already-published packages (`skip-existing` semantics): a partially-failed
|
||||
publish is healed by re-running instead of yanking, because the remaining
|
||||
identical artifacts complete the set.
|
||||
3. **validate**: `needs: publish`. Clean venv; poll the real index until all
|
||||
three resolve (propagation lag, bounded ~10 min); `pip install
|
||||
omnigent==X omnigent-client==X omnigent-ui-sdk==X` (exact rc pins resolve
|
||||
without `--pre`); assert `omnigent --version` == X; import smoke. Replaces
|
||||
the manual venv recipe.
|
||||
|
||||
`destination=test-pypi` and `dry-run=true` inputs stay for rehearsals, but the
|
||||
standard flow no longer uses TestPyPI (per new policy: rc goes to real PyPI as a
|
||||
PEP 440 prerelease, which default `pip install omnigent` never resolves — safer
|
||||
than the TestPyPI dependency-confusion dance RELEASING.md currently documents).
|
||||
|
||||
Net: one dispatch, one approval click, per phase.
|
||||
|
||||
## Workflow 3 — `finalize-release.yml` (new, final releases only)
|
||||
|
||||
`workflow_dispatch` input: `tag` (e.g. `v0.6.0`).
|
||||
|
||||
1. **checks** (all fail with actionable links):
|
||||
- tag is a final `vX.Y.Z`; a *draft* GH release exists for it;
|
||||
- PyPI serves all three packages at the version (JSON API) — never publish
|
||||
release notes for something uninstallable;
|
||||
- the `auto/changelog/vX.Y.Z` CHANGELOG PR is merged;
|
||||
- **docs sweep**: zero open PRs in `omnigent-site` with base `X.Y-docs` —
|
||||
the deterministic form of "all release docs PRs reviewed + merged/closed".
|
||||
Each open PR is listed in the summary; resolving them stays human work.
|
||||
2. **publish** behind a `publish-release` environment (required reviewer).
|
||||
Approving *is* the attestation "I reviewed/curated the draft notes."
|
||||
Then, with the App token: `gh release edit vX.Y.Z --draft=false --latest`.
|
||||
Two footguns handled here that have bitten before: `--latest` must be
|
||||
explicit (API publishes don't set `make_latest`), and the App token (not
|
||||
`GITHUB_TOKEN`) ensures the `release: published` event actually fires
|
||||
`publish-changelog.yml`, which opens the site release-post PR and the
|
||||
`X.Y-docs → main` docs-publish PR.
|
||||
3. **summary**: links to the two site PRs awaiting merge.
|
||||
|
||||
rc releases never finalize: their GH drafts stay unpublished prerelease drafts
|
||||
(**decided**: keep exactly today's pattern — rc drafts are never published on
|
||||
GitHub).
|
||||
|
||||
## Workflow 4 — `update-homebrew.yml` (new, final releases only)
|
||||
|
||||
Current state of `omnigent-ai/homebrew-tap`: a homebrew-core-style tap that is
|
||||
already 2/3 automated —
|
||||
|
||||
- `Formula/omnigent.rb`: `Language::Python::Virtualenv` formula; stable
|
||||
installs the **PyPI sdist** (url + sha256) with **94 pinned Python
|
||||
resources**; a few deps come from brewed formulae instead
|
||||
(`certifi`/`cryptography`/`pydantic`/`rpds-py` as `:no_linkage`, plus
|
||||
`python@3.14`, `libyaml`, `tmux`, Rust build deps); hand-maintained
|
||||
platform-conditional `google-antigravity` wheel stanzas; bottles hosted on
|
||||
the tap's GitHub releases.
|
||||
- `tests.yml`: `brew test-bot` on 3 macOS runners — on every PR it builds the
|
||||
formula (i.e. builds the bottles) and uploads them as artifacts.
|
||||
- `publish.yml`: on the `pr-pull` label, `brew pr-pull` publishes the bottles
|
||||
to a tap release, rewrites the bottle block, merges to main.
|
||||
|
||||
The **only missing link is the bump PR** — nobody opens it, which is exactly
|
||||
why the tap froze at 0.2.0 (2026-06-23) while PyPI moved to 0.5.1. The
|
||||
`omnigent-desktop` cask needs nothing: it is `version :latest` /
|
||||
`sha256 :no_check` against `omnigent.ai/download/mac`, i.e. evergreen.
|
||||
|
||||
New workflow in omnigent-ai/omnigent, shaped exactly like
|
||||
`publish-changelog.yml` (event + dispatch fallback, App token, idempotent
|
||||
PR-opening):
|
||||
|
||||
- Triggers: `release: types: [published]` (fires automatically from
|
||||
finalize's App-token publish; guarded to final `vX.Y.Z` like
|
||||
publish-changelog) + `workflow_dispatch(tag)` for retries and catch-up.
|
||||
- Steps: bounded-poll the PyPI JSON API until the new sdist is visible; on a
|
||||
macOS runner with `Homebrew/actions/setup-homebrew`, check out the tap via
|
||||
an App token (App installed on `homebrew-tap`); rewrite `url`/`sha256` from
|
||||
the PyPI metadata and drop any `revision`; regenerate the resource pins with
|
||||
`brew update-python-resources` (excluding the brewed-formula deps and the
|
||||
hand-maintained `google-antigravity` stanzas so they're preserved); run
|
||||
`brew style`/`brew audit` as a sanity gate; push `bump-omnigent-<version>`
|
||||
and open (or update) the tap PR.
|
||||
- From there the tap's own machinery takes over: test-bot builds the bottles
|
||||
on the PR; a human reviews the resource diff and applies `pr-pull`; the
|
||||
existing publish workflow bottles + merges. One review + one label click per
|
||||
final release — the human gate the tap already has, kept.
|
||||
|
||||
First run doubles as the **catch-up**: dispatch with `tag=v0.5.1` to jump the
|
||||
formula 0.2.0 → 0.5.1 (expect that one resource diff to be large).
|
||||
|
||||
## Who can trigger a release (maintainer-only)
|
||||
|
||||
`workflow_dispatch` is runnable by anyone with write access, which is too
|
||||
broad. Every release workflow (`release.yml`, `finalize-release.yml`,
|
||||
`update-homebrew.yml`'s dispatch path) gets a first `authorize` job that all
|
||||
other jobs `need`:
|
||||
|
||||
```
|
||||
role=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/${GITHUB_ACTOR}/permission" --jq .role_name)
|
||||
case "$role" in admin|maintain) ;; *) fail "release workflows require maintain/admin" ;; esac
|
||||
```
|
||||
|
||||
`github.actor` on a dispatch is the dispatcher and can't be spoofed; roles
|
||||
come from repo settings, so there's no hand-kept allowlist to rot. Defense in
|
||||
depth stacks three independent layers: this actor gate (highest repo
|
||||
privilege to start anything), the `v[0-9]*` **tag ruleset** (create/update/
|
||||
delete restricted to the omnigent-ci App + admins — even a bypassed workflow
|
||||
can't tag; goose's primary gate), and the secure repo's own access model
|
||||
(admin/maintain to dispatch, environment reviewers on the upload). The
|
||||
alternative — a required-reviewer environment on the first job — adds an
|
||||
approval click and a separately-maintained reviewer list for no additional
|
||||
precision; rejected.
|
||||
|
||||
## What stays human, on purpose
|
||||
|
||||
1. Choosing version/timing/base commit (the dispatches).
|
||||
2. Secure-repo environment approval — publish authorization.
|
||||
3. Release-notes curation + the finalize approval that attests to it.
|
||||
4. Content review merges: CHANGELOG PR, bump-main PR, doc PRs on `X.Y-docs`,
|
||||
the release-post PR, the docs-publish PR.
|
||||
4a. The homebrew-tap bump PR: review the resource diff, apply `pr-pull`.
|
||||
5. Yank decisions when something shipped broken (policy unchanged: never reuse
|
||||
a version; `skip-existing` re-runs heal *partial* publishes, yank handles
|
||||
*bad* ones).
|
||||
|
||||
## Recovery model
|
||||
|
||||
Any run can be re-dispatched with identical inputs after any failure; every
|
||||
step converges or fails loudly rather than duplicating. Pre-publish mistakes
|
||||
(wrong commit tagged): delete tag + draft, re-dispatch — unchanged from
|
||||
RELEASING.md. Post-publish: fix forward to the next version.
|
||||
|
||||
## Cleanups this unlocks
|
||||
|
||||
- **Delete `release-omnigent.yml`** — its own header says "to be deleted once
|
||||
the secure path has done a prod release", which has now happened repeatedly.
|
||||
Also retire its `pypi`/`test-pypi` Trusted Publishers on PyPI: a live trusted
|
||||
publisher pointing at the public repo is standing attack surface.
|
||||
- Rewrite `RELEASING.md` around the dispatches, demoting today's CLI runbook to
|
||||
a break-glass appendix. The `uv.lock` hand-edit instructions disappear.
|
||||
|
||||
## What peer projects do (survey, 2026-07)
|
||||
|
||||
### pi (`earendil-works/pi`)
|
||||
|
||||
Lean solo-maintainer automation, no release branches, no rc channel — cadence
|
||||
(a release every 1–2 days) substitutes for candidates. Mechanics worth noting:
|
||||
|
||||
- **Draft-then-flip**: binaries staged on a *draft* GH release; the release is
|
||||
made public only after npm publish succeeds; any failure deletes the draft;
|
||||
the workflow *refuses to mutate an already-published release*.
|
||||
- **Idempotent publish**: `npm view <pkg>@<ver>` before every upload, skip if
|
||||
present — re-running a tag workflow after a partial failure heals it.
|
||||
(The direct inspiration for the `skip-existing` PyPI probe above.)
|
||||
- **Recovery dispatch**: the tag-triggered build workflow has a
|
||||
`workflow_dispatch` twin with `tag` + `source_ref`, labeled "release
|
||||
recovery only".
|
||||
- Lockstep versions across 4 npm packages enforced by one sync script with a
|
||||
check mode (their `sync-versions.js` ≈ our `update_versions.py`).
|
||||
- Release notes: maintainer runs pi's own `/cl` prompt to audit CHANGELOG
|
||||
entries with a human-confirm step — the same posture as our
|
||||
`draft-release-notes.yml` + human curation.
|
||||
- Pre-publish smoke is a *manual* isolated-install checklist in AGENTS.md;
|
||||
**no automated post-publish validation exists** in their CI.
|
||||
|
||||
### opencode (`anomalyco/opencode`)
|
||||
|
||||
Continuous-publish machine: every push to `dev` ships an npm prerelease under
|
||||
a branch-named dist-tag; an hourly bot assembles a `beta` branch (with their
|
||||
own agent resolving merge conflicts); a real "latest" release is **one
|
||||
`workflow_dispatch` click** (bump dropdown) — build, sign, notarize, npm,
|
||||
Docker, AUR, Homebrew, LLM-authored release notes, Discord announce, all
|
||||
unattended. Relevant mechanics:
|
||||
|
||||
- Bot pushes via a **GitHub App token** (`create-github-app-token`), never a
|
||||
PAT — same identity pattern as our omnigent-ci App.
|
||||
- Same idempotent already-published-skip before every npm publish.
|
||||
- npm auth is OIDC trusted publishing, zero registry tokens in CI.
|
||||
- Fully autonomous LLM changelog with *no* human review gate, and no
|
||||
environment protection on the publish job at all — a rigor level below what
|
||||
a Databricks-governed project should copy.
|
||||
- Docs are evergreen/unversioned, deployed on push, fully decoupled from
|
||||
releases.
|
||||
|
||||
### Cross-cutting (both)
|
||||
|
||||
- **Neither peer automates post-publish validation** (clean-env install of
|
||||
the just-published artifact + run it). The `validate` job in the secure repo
|
||||
puts omnigent ahead of both, not just at parity.
|
||||
- **Neither has an rc→final concept** — both rebuild rather than promote.
|
||||
Rebuilding the final from the same `branch-X.Y` (rather than promoting rc
|
||||
artifacts) is also what our model does; PyPI's no-reupload rule makes
|
||||
rebuild-and-restamp the pragmatic norm.
|
||||
- Both decouple docs publishing from the release pipeline structurally — which
|
||||
supports keeping our site PRs as separate human-reviewed merges rather than
|
||||
folding them into `release.yml`.
|
||||
|
||||
### cline (`cline/cline`)
|
||||
|
||||
Three independent release trains (VS Code extension, CLI, SDK), all
|
||||
`workflow_dispatch`, all preconditioned on a *human-authored* version-bump +
|
||||
changelog PR — despite appearances, no bot writes their bumps. Worth stealing:
|
||||
|
||||
- **Tag/SHA idempotency guard** (`ext-vscode-publish-stable.yml`, "Resolve
|
||||
Release Tag"): tag exists → assert it points at the tested SHA (no-op on
|
||||
match, hard-fail on mismatch); tag absent → create it from the tested SHA
|
||||
after asserting that SHA is an ancestor of `main`. Verbatim the semantics
|
||||
`release.yml`'s plan/execute jobs adopt.
|
||||
- **Gate placement**: the named-required-reviewer GitHub Environment guards
|
||||
*only* the VS Code Marketplace publish (highest blast radius); CLI/SDK get a
|
||||
typed `confirm_publish: "publish"` string. Principle: spend the heavyweight
|
||||
second-person gate on the irreversible step only — for omnigent, that is the
|
||||
secure-repo PyPI upload, which already has exactly such an environment.
|
||||
- **Changelog-as-gate**: publish hard-fails if the changelog's top entry ≠ the
|
||||
version, then reuses that section as the release body (and a Slack post).
|
||||
Our equivalent is finalize's "CHANGELOG PR merged" check.
|
||||
- No release branches, no rc versions (marketplace "pre-release" is a flag on
|
||||
a normal version), no post-publish validation, no rollback story.
|
||||
|
||||
### kilocode (`Kilo-Org/kilocode`)
|
||||
|
||||
Product forked from cline, but the *release pipeline* is forked from opencode
|
||||
(they even poll `anomalyco/opencode` releases to sync). Main train: **one
|
||||
dispatch** (`bump` dropdown, `pre_release` defaults true) → version → build →
|
||||
**validate matrix** (executes the built binary on macOS/Linux/Windows/Alpine)
|
||||
→ **smoke-test** (real eval tasks against the *draft release's* assets) →
|
||||
unattended publish to npm/Marketplace/GHCR/AUR/brew. No environment gate at
|
||||
all on that train — below the rigor a Databricks-governed project should copy.
|
||||
The interesting part is the **JetBrains train**, the only peer flow with true
|
||||
rc→stable promotion: `prepare-jetbrains-release.yml` (`kind: rc|stable`,
|
||||
`version`, `from_tag`) opens a release branch + PR; the human *merge* of that
|
||||
PR is the approval gate; `publish-jetbrains.yml` fires on the merge, with a
|
||||
dispatch fallback for re-runs; rc tags chain `-rc.1 … -rc.15 → stable`.
|
||||
|
||||
**Considered variant for omnigent** (from the JetBrains pattern): have
|
||||
`release.yml` open a bump *PR* onto `branch-X.Y` instead of pushing directly,
|
||||
making the merge a second-person cut-approval and running CI on the bump
|
||||
commit. Rejected as the default: the bump is deterministic robot output
|
||||
(`update_versions.py` + `check`), the cut is fully reversible, the secure
|
||||
repo's gates re-verify everything against the tag before anything publishes,
|
||||
and the extra merge per rc works against the 1–2-runs goal. Easy to switch to
|
||||
later if a second-person cut gate is ever wanted.
|
||||
|
||||
### goose (`block/goose` → now `aaif-goose/goose`)
|
||||
|
||||
The closest org-shape analogue (big-company compliance, busy monorepo,
|
||||
canary + stable channels, release branches). Minor release = weekly scheduled
|
||||
bump PR → human merge → auto-cut `release/X.Y.0` + release PR → human runs two
|
||||
copy-pasted `git tag && git push` commands → everything downstream (10-platform
|
||||
build, signing, GHCR + SLSA, LLM release notes, Discord, auto-created next
|
||||
hotfix branch) is automatic. ~5 human actions per minor. Findings that matter:
|
||||
|
||||
- **Their gate is a repo-wide tag-protection ruleset** (create/update/delete
|
||||
blocked on *all* tags without bypass privilege), not environment reviewers —
|
||||
environments are used only to scope secrets. Cheap, auditable.
|
||||
- **They hit the `GITHUB_TOKEN` event-suppression gotcha in production**:
|
||||
their LLM release-notes workflow runs on `workflow_run` *specifically*
|
||||
because `release: published` doesn't fire for token-authored releases — the
|
||||
same trap our App-token choices are designed around (and that
|
||||
`draft-release-notes.yml` already dodges the same way).
|
||||
- Their SDK packages **silently drifted out of lockstep** because nothing
|
||||
asserts it — the failure mode our `update_versions.py check` prevents, and
|
||||
an argument for running it in CI permanently (see hardening below).
|
||||
- Canary = a single floating GH release overwritten in place; promotion is
|
||||
always rebuild-from-source, never relabel.
|
||||
- No dry-run, no post-publish validation, dependency scan *not* wired as a
|
||||
publish gate, idempotency uneven, no rollback runbook.
|
||||
|
||||
### hermes (`NousResearch/hermes-agent`)
|
||||
|
||||
Real and public. CalVer tags (`v2026.7.7.2`), no release branches, no rc
|
||||
channel, weekly cadence with same-day suffixed hotfixes; releasing is a local
|
||||
`release.py` a maintainer runs (~3 actions), with GH Actions as reactive side
|
||||
effects. Worth stealing:
|
||||
|
||||
- **Lockstep-as-a-test**: a real CI test asserts their four version locations
|
||||
agree — drift is caught structurally no matter how it happened (bad merge,
|
||||
cherry-pick, manual edit), not just when the bump script runs.
|
||||
- **PyPI publish uses `skip-existing: true`** (pypa action) — direct precedent
|
||||
for the partial-publish healing proposed for the secure repo.
|
||||
- **Re-publish escape hatch**: `upload_to_pypi.yml` has a dispatch with a
|
||||
`confirm_tag` input documented as "re-publish an existing tag" — the
|
||||
idempotent-retry shape our secure-repo dispatch already has via `ref`.
|
||||
- Bounded poll-with-warning (not hard-fail) when reading back a just-created
|
||||
release/tag that may lag — adopted in the `validate` job's PyPI polling.
|
||||
- Cautionary tale: their dependency-manifest review ruleset was empirically
|
||||
self-merged around on a real release PR — review gates that the same person
|
||||
can approve are decoration. (The secure repo's separate-org reviewer set
|
||||
doesn't have this hole; keep it that way.)
|
||||
|
||||
### Cross-cutting (all six)
|
||||
|
||||
- **Nobody automates post-publish validation** — the secure repo `validate`
|
||||
job is ahead of every peer surveyed.
|
||||
- **Nobody has versioned docs** — all continuous-deploy latest-only. The
|
||||
`X.Y-docs` staging design has no prior art to borrow; it's already built and
|
||||
just needs the sweep gate.
|
||||
- **Nobody has a backport/patch-branch story** as good as `branch-X.Y` +
|
||||
cherry-pick; cline maintains one frozen legacy branch, kilocode has nothing.
|
||||
- Pre-publish smoke against built artifacts (kilocode) ≈ the secure repo's
|
||||
existing smoke-install gate. Parity, not a gap.
|
||||
- **Nobody documents rollback/yank** — RELEASING.md's recovery section is
|
||||
ahead of all six; the new workflows keep it (and make partial-publish
|
||||
recovery automatic via skip-existing).
|
||||
- rc→final promotion is rebuild-from-the-pinned-ref everywhere it exists at
|
||||
all (goose canary→stable, kilocode JetBrains) — never artifact relabeling.
|
||||
Validates our model: the final independently re-runs build+scan+publish
|
||||
from `branch-X.Y`, which the mandatory dependency scan requires anyway.
|
||||
- omnigent's mandatory scan-gates-publish + separate-org publisher is
|
||||
**stricter than every peer surveyed** (goose's scan isn't a gate; hermes's
|
||||
review gate was self-merged around; opencode/kilocode publish unattended).
|
||||
|
||||
## Hardening extras (cheap, independent of the workflows)
|
||||
|
||||
- **Run `update_versions.py check` in CI permanently** (a test or `ci.yml`
|
||||
step), not just inside bump/release workflows — goose's SDKs silently
|
||||
drifted out of lockstep for lack of exactly this assertion (hermes has it
|
||||
and it works).
|
||||
- **Tag ruleset on `v[0-9]*`**: restrict create/update/delete to maintainers +
|
||||
the omnigent-ci App. Today any write-access account can push a version tag
|
||||
and set off the draft-release + docker-publish chain; goose treats tag
|
||||
protection as their primary release gate.
|
||||
|
||||
## Decisions (2026-07-14)
|
||||
|
||||
1. **Secure-repo restructure: approved direction** — gates → env-approval →
|
||||
publish (skip-existing) → validate, one dispatch per phase.
|
||||
2. **rc GH drafts are never published** — keep today's pattern exactly.
|
||||
3. **bump PRs move to the App token** so CI runs on them (empty-commit
|
||||
workaround retired).
|
||||
4. **Release workflows are maintainer-only**: `authorize` actor-role gate
|
||||
(admin/maintain) + the `v[0-9]*` tag ruleset as backstop.
|
||||
5. **Homebrew joins the pipeline** via `update-homebrew.yml` on
|
||||
`release: published`; tap-side human gate (`pr-pull` label) kept.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Environment `publish-release` reviewer set = who may finalize a release.
|
||||
2. `brew update-python-resources` vs. the hand-maintained formula sections:
|
||||
confirm on the catch-up run that the exclusion flags preserve the
|
||||
`google-antigravity` platform stanzas and the brewed-dep comments, or keep
|
||||
those sections behind guard comments the updater skips.
|
||||
3. Tap bottle coverage (currently arm64 macOS only) — widen the test-bot
|
||||
matrix? Orthogonal to this pipeline; tracked here so it isn't forgotten.
|
||||
@@ -81,24 +81,12 @@ drift negligible (~2 ms/turn).
|
||||
|
||||
| Journey | Operation timed |
|
||||
| --- | --- |
|
||||
| `session_cold_start` | Spawn a **fresh runner process**, wait for its tunnel, bind a session, and drive the first turn to `idle` — the full new-conversation cold path |
|
||||
| `session_cold_start` | Create+bind a fresh session and drive its first turn to `idle` (runner spawn + executor construction + turn) |
|
||||
| `warm_turn` | Drive a turn on an already-warm session — steady-state dispatch overhead |
|
||||
| `time_to_first_token` | Post a turn; time to the first streamed `output_text` delta |
|
||||
| `interrupt` | Interrupt a running (gated) turn; time to cancellation |
|
||||
| `read_runner_file` | `GET .../environments/default/filesystem/{path}` — server → runner filesystem read proxy |
|
||||
|
||||
**`session_cold_start` spawns a real runner.** The env spawns one runner at
|
||||
boot, but the warm journeys reuse it — so `session_cold_start` instead spawns a
|
||||
*fresh* runner subprocess per iteration and waits for its reverse tunnel to
|
||||
register before binding and driving the turn. That captures the runner process
|
||||
start + tunnel handshake that a real new conversation always pays (and that a
|
||||
host-launched session pays on its first message), not just the sub-second
|
||||
executor-construction + first-turn overhead. Each iteration terminates its
|
||||
runner afterward, so at most one extra runner is ever live. Each spawned runner
|
||||
mints its own binding token and derives its `runner_id` from it (so tunnel,
|
||||
mint, and session binding all agree on one id) and registers over loopback,
|
||||
exactly like the boot runner — a fully independent runner.
|
||||
|
||||
`read_runner_file` needs a runner but does **not** drive a turn or call the LLM:
|
||||
its setup plants a file via `PUT`, and the timed op is the proxied read (a
|
||||
localhost round-trip). Being far cheaper than a turn, it uses a higher iteration
|
||||
@@ -140,8 +128,7 @@ that a schema change hasn't broken seeding.
|
||||
## Backends
|
||||
|
||||
`--database-uri` selects the DB; the report's `backend` field (`sqlite` /
|
||||
`postgres` / `mysql`) is derived from the URI scheme so results group by
|
||||
backend.
|
||||
`postgres`) is derived from the URI scheme so results group by backend.
|
||||
|
||||
- **SQLite** (default) — in-process; fast, but not prod-representative.
|
||||
- **Postgres** — `postgresql+psycopg://user@host:5432/db` (the fully-qualified
|
||||
@@ -149,11 +136,6 @@ backend.
|
||||
Requires `psycopg[binary]` (the `databricks` extra). Matches prod's
|
||||
round-trip/pooling profile. Stand up a local one with
|
||||
`docker run -e POSTGRES_PASSWORD=… -p 5432:5432 postgres:16`.
|
||||
- **MySQL** — `mysql+mysqldb://user@host:3306/db`. Requires the `mysqlclient`
|
||||
driver (`pip install mysqlclient`, which needs the `libmysqlclient-dev`
|
||||
system library) — it is not in any extra. A supported backend, though prod
|
||||
runs on Postgres. Stand up a local one with
|
||||
`docker run -e MYSQL_ROOT_PASSWORD=… -e MYSQL_DATABASE=benchdb -p 3306:3306 mysql:8.0`.
|
||||
|
||||
## Output → Databricks → dashboard
|
||||
|
||||
@@ -175,7 +157,7 @@ document without running the harness.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schema_version": 2,
|
||||
"schema_version": 1,
|
||||
"generated_at": "<ISO-8601 UTC>",
|
||||
"git_sha": "<HEAD sha>",
|
||||
"git_branch": "<branch>",
|
||||
@@ -187,8 +169,7 @@ document without running the harness.
|
||||
"journeys": {
|
||||
"<journey name>": {
|
||||
"kind": "latency" | "throughput",
|
||||
"backend": "sqlite" | "postgres" | "mysql",
|
||||
"needs_runner": false, // hardcoded per journey: HTTP=false, full-turn=true
|
||||
"backend": "sqlite" | "postgres",
|
||||
"runs": [ // one per --runs
|
||||
{"n_success": N, "n_failures": N, "failures": {"HTTP 500": 1},
|
||||
"wall_time_s": …, "mean_ms": …, "p50_ms": …, "p95_ms": …,
|
||||
@@ -224,11 +205,10 @@ creds).
|
||||
## CI
|
||||
|
||||
`.github/workflows/benchmark.yml` runs nightly (and on dispatch) as a backend
|
||||
matrix — `sqlite`, `postgres` (a `postgres:16` service container), and `mysql`
|
||||
(a `mysql:8.0` service container; the `mysqlclient` driver is installed on that
|
||||
leg only). Each leg seeds a corpus (SQLite reuses a cache keyed on the schema
|
||||
head + `seed.py` + corpus config, so a migration busts the cache and forces a
|
||||
reseed; Postgres and MySQL are fresh per run), runs the benchmark, and uploads
|
||||
matrix — `sqlite` and `postgres` (a `postgres:16` service container). Each leg
|
||||
seeds a corpus (SQLite reuses a cache keyed on the schema head + `seed.py` +
|
||||
corpus config, so a migration busts the cache and forces a reseed; Postgres is
|
||||
fresh per run), runs the benchmark, and uploads
|
||||
`benchmark-results-<backend>-<run_id>.json`. The workspace notebook pulls those
|
||||
artifacts.
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ from typing import IO
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
from omnigent.host.identity import HOST_ID_ENV_VAR, HOST_NAME_ENV_VAR
|
||||
from omnigent.runner.identity import OMNIGENT_INTERNAL_WS_ORIGIN, token_bound_runner_id
|
||||
from tests._helpers.compat import (
|
||||
apply_runner_env,
|
||||
@@ -55,10 +54,6 @@ _HEALTH_TIMEOUT_S = 90.0
|
||||
_MOCK_TIMEOUT_S = 15.0
|
||||
_POLL_INTERVAL_S = 0.2
|
||||
_TURN_TIMEOUT_S = 180.0
|
||||
# Budget for the host daemon (session_cold_start journey, with_host) to connect
|
||||
# its tunnel and register in the hosts table after being spawned. Covers
|
||||
# interpreter start + imports + the reverse-tunnel handshake.
|
||||
_HOST_ONLINE_TIMEOUT_S = 60.0
|
||||
|
||||
# Terminal SSE events — if one arrives before any delta, the turn produced no
|
||||
# streamed text (a failure for the TTFT journey).
|
||||
@@ -89,19 +84,6 @@ def _find_free_port() -> int:
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _omni_executable() -> str:
|
||||
"""The ``omni`` console script beside the (compat-aware) interpreter.
|
||||
|
||||
``server_executable()`` returns the interpreter the server/runner subprocess
|
||||
should run under — ``sys.executable`` normally, or a pinned older build's
|
||||
python in cross-version compat mode. The ``omni`` console script is
|
||||
installed next to that interpreter (``[project.scripts]`` in pyproject), so
|
||||
deriving it from the same directory launches the real user-facing command
|
||||
(``omni server`` / ``omni host``) while still honoring the compat pin.
|
||||
"""
|
||||
return str(Path(server_executable()).with_name("omni"))
|
||||
|
||||
|
||||
class BenchEnvironment:
|
||||
"""Async context manager owning the benchmark's server (± runner + mock).
|
||||
|
||||
@@ -109,12 +91,6 @@ class BenchEnvironment:
|
||||
v1 HTTP-journey path. When ``True``, also spawn the mock LLM and a
|
||||
runner and wire the policy classifier at the mock — the phase-2
|
||||
full-turn path.
|
||||
:param with_host: When ``True`` (implies ``with_runner``), additionally
|
||||
spawn a real ``omnigent host`` daemon. Additive over ``with_runner``:
|
||||
the boot runner still serves the warm journeys, while the daemon lets
|
||||
the ``session_cold_start`` journey create host-bound sessions that fire
|
||||
``host.launch_runner`` and launch their OWN fresh runner — so the first
|
||||
message races the runner's boot, reproducing the true UI cold path.
|
||||
:param database_uri: SQLAlchemy URI the server boots against. ``None``
|
||||
(default) uses a fresh throwaway SQLite file in the temp dir — the
|
||||
empty-DB path. Pass a pre-seeded URI (e.g. a seeded SQLite file, or a
|
||||
@@ -130,34 +106,23 @@ class BenchEnvironment:
|
||||
self,
|
||||
*,
|
||||
with_runner: bool = False,
|
||||
with_host: bool = False,
|
||||
database_uri: str | None = None,
|
||||
harness: str = _DEFAULT_HARNESS,
|
||||
model: str = _DEFAULT_MODEL,
|
||||
) -> None:
|
||||
# with_host is additive over with_runner: the boot runner still serves
|
||||
# the warm journeys, and the host daemon additionally lets the cold-start
|
||||
# journey create host-bound sessions that launch their own runners.
|
||||
self.with_host = with_host
|
||||
self.with_runner = with_runner or with_host
|
||||
self.with_runner = with_runner
|
||||
self.database_uri = database_uri
|
||||
self.harness = harness
|
||||
self.model = model
|
||||
self.base_url = ""
|
||||
self.mock_url = ""
|
||||
self.runner_id = ""
|
||||
self.host_id = ""
|
||||
self.host_workspace = ""
|
||||
self.client: httpx.AsyncClient | None = None
|
||||
|
||||
self._tmp = Path("/tmp") / f"omni-bench-{uuid.uuid4().hex[:8]}"
|
||||
self._mock_proc: subprocess.Popen[bytes] | None = None
|
||||
self._server_proc: subprocess.Popen[bytes] | None = None
|
||||
self._runner_proc: subprocess.Popen[bytes] | None = None
|
||||
self._host_proc: subprocess.Popen[bytes] | None = None
|
||||
# Base env retained so the host daemon is built identically to the boot
|
||||
# runner's server-facing env (worktree source, mock LLM routing).
|
||||
self._runner_base_env: dict[str, str] = {}
|
||||
self._log_handles: list[IO[bytes]] = []
|
||||
self._agent_cache: dict[str, str] = {}
|
||||
|
||||
@@ -207,33 +172,15 @@ class BenchEnvironment:
|
||||
base_env["OPENAI_BASE_URL"] = f"{self.mock_url}/v1"
|
||||
# Prepend the worktree so subprocesses import this branch's source.
|
||||
apply_server_env(base_env, _REPO_ROOT)
|
||||
# Retained so the host daemon (with_host) is built with the same
|
||||
# server-facing env as the boot runner.
|
||||
self._runner_base_env = base_env
|
||||
|
||||
self._server_proc = self._spawn_server(port, base_env, binding_token, artifact_dir)
|
||||
if self.with_runner:
|
||||
self._runner_proc = self._spawn_runner(base_env, binding_token)
|
||||
self._wait_ready()
|
||||
# The host daemon is ADDITIVE — the boot runner above still serves the
|
||||
# warm journeys; the daemon exists so the cold-start journey can create
|
||||
# host-bound sessions that launch their OWN fresh runners on demand
|
||||
# (the race the cold path measures). The two never share a runner id.
|
||||
if self.with_host:
|
||||
self._host_proc = self._spawn_host(base_env)
|
||||
self._wait_host_online()
|
||||
|
||||
def _stop(self) -> None:
|
||||
"""Terminate host, runner, server, and mock; remove the temp dir."""
|
||||
# Host first: SIGTERM-ing the daemon reaps the runners IT spawned (they
|
||||
# are daemon-owned children), so it must go before the server so those
|
||||
# runners' tunnels close cleanly.
|
||||
for proc in (
|
||||
self._host_proc,
|
||||
self._runner_proc,
|
||||
self._server_proc,
|
||||
self._mock_proc,
|
||||
):
|
||||
"""Terminate runner, server, and mock; remove the temp dir."""
|
||||
for proc in (self._runner_proc, self._server_proc, self._mock_proc):
|
||||
if proc is not None and proc.poll() is None:
|
||||
proc.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
@@ -274,7 +221,9 @@ class BenchEnvironment:
|
||||
# four slashes; the temp path is absolute.
|
||||
db_uri = self.database_uri or f"sqlite:///{self._tmp / 'bench.db'}"
|
||||
args = [
|
||||
_omni_executable(),
|
||||
server_executable(),
|
||||
"-m",
|
||||
"omnigent.cli",
|
||||
"server",
|
||||
"--port",
|
||||
str(port),
|
||||
@@ -321,36 +270,10 @@ class BenchEnvironment:
|
||||
# on teardown, rather than in the launch cwd (its default).
|
||||
workspace = self._tmp / "workspace"
|
||||
workspace.mkdir(exist_ok=True)
|
||||
return self._spawn_runner_process(
|
||||
base_env,
|
||||
binding_token,
|
||||
runner_id=self.runner_id,
|
||||
workspace=workspace,
|
||||
log_name="runner.log",
|
||||
)
|
||||
|
||||
def _spawn_runner_process(
|
||||
self,
|
||||
base_env: dict[str, str],
|
||||
binding_token: str,
|
||||
*,
|
||||
runner_id: str,
|
||||
workspace: Path,
|
||||
log_name: str,
|
||||
) -> subprocess.Popen[bytes]:
|
||||
"""Spawn one runner subprocess under *runner_id* + *binding_token*.
|
||||
|
||||
Factored out of :meth:`_spawn_runner` so the ``session_cold_start``
|
||||
journey can spawn additional runners on demand, each under its own id,
|
||||
binding token, and workspace. The caller must pair *runner_id* with the token
|
||||
it derives from (``token_bound_runner_id(binding_token)``): the runner
|
||||
derives its managed-mint URL from the token internally, so a mismatch
|
||||
would register the tunnel under one id but mint under another (→ 401).
|
||||
"""
|
||||
runner_env = apply_runner_env(
|
||||
{
|
||||
**base_env,
|
||||
"OMNIGENT_RUNNER_ID": runner_id,
|
||||
"OMNIGENT_RUNNER_ID": self.runner_id,
|
||||
"OMNIGENT_RUNNER_TUNNEL_BINDING_TOKEN": binding_token,
|
||||
"OMNIGENT_RUNNER_PARENT_PID": str(os.getpid()),
|
||||
"RUNNER_SERVER_URL": self.base_url,
|
||||
@@ -361,40 +284,7 @@ class BenchEnvironment:
|
||||
[runner_executable(), "-m", "omnigent.runner._entry"],
|
||||
env=runner_env,
|
||||
cwd=compat_runner_cwd(),
|
||||
stdout=self._log(log_name),
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
|
||||
def _spawn_host(self, base_env: dict[str, str]) -> subprocess.Popen[bytes]:
|
||||
"""Spawn a real ``omni host`` daemon against the bench server.
|
||||
|
||||
Runs the user-facing ``omni host --server`` command — the same daemon a
|
||||
developer starts by hand. Identity comes from :data:`HOST_ID_ENV_VAR` /
|
||||
:data:`HOST_NAME_ENV_VAR`: with both set, ``load_or_create_host_identity``
|
||||
returns that identity WITHOUT reading or writing any ``config.yaml``, so
|
||||
the daemon never touches the developer's real ``~/.omnigent`` (nor
|
||||
collides with a sibling bench leg). ``--non-interactive`` keeps it from
|
||||
ever launching a browser login (moot for the loopback server, which is
|
||||
not Databricks-fronted, but explicit for CI). The daemon self-registers
|
||||
over loopback (single-user ``RESERVED_USER_LOCAL`` owner, no token) and
|
||||
launches runners on demand when the server sends ``host.launch_runner``.
|
||||
"""
|
||||
# Bare 32-char hex uuid — host_id is a Uuid16 (binary) column, so it
|
||||
# must be a valid uuid (a synthetic "host_bench_…" string no longer fits).
|
||||
self.host_id = uuid.uuid4().hex
|
||||
workspace = self._tmp / "host-workspace"
|
||||
workspace.mkdir(exist_ok=True)
|
||||
self.host_workspace = str(workspace)
|
||||
host_env = {
|
||||
**base_env,
|
||||
HOST_ID_ENV_VAR: self.host_id,
|
||||
HOST_NAME_ENV_VAR: f"bench-host-{self.host_id[-8:]}",
|
||||
}
|
||||
return subprocess.Popen(
|
||||
[_omni_executable(), "host", "--server", self.base_url, "--non-interactive"],
|
||||
env=host_env,
|
||||
cwd=str(workspace),
|
||||
stdout=self._log("host-daemon.log"),
|
||||
stdout=self._log("runner.log"),
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
|
||||
@@ -425,39 +315,12 @@ class BenchEnvironment:
|
||||
raise RuntimeError(f"server not ready within {_HEALTH_TIMEOUT_S}s; logs in {self._tmp}")
|
||||
|
||||
def _runner_ready(self) -> bool:
|
||||
"""Whether the boot runner reports online (always ``True`` server-only)."""
|
||||
"""Whether the runner reports online (always ``True`` server-only)."""
|
||||
if not self.with_runner:
|
||||
return True
|
||||
status = httpx.get(f"{self.base_url}/v1/runners/{self.runner_id}/status", timeout=2)
|
||||
return status.status_code == 200 and status.json().get("online") is True
|
||||
|
||||
def _wait_host_online(self) -> None:
|
||||
"""Block until the host daemon's row reads ``status=online``.
|
||||
|
||||
Polls ``GET /v1/hosts`` (the single-user owner is ``local``) until the
|
||||
daemon we spawned has connected its tunnel and been upserted online, so
|
||||
a host-bound session-create has a live launch target.
|
||||
"""
|
||||
deadline = time.monotonic() + _HOST_ONLINE_TIMEOUT_S
|
||||
while time.monotonic() < deadline:
|
||||
if self._host_proc is not None and self._host_proc.poll() is not None:
|
||||
raise RuntimeError(
|
||||
f"host daemon exited (code {self._host_proc.returncode}) before "
|
||||
f"coming online; logs in {self._tmp}"
|
||||
)
|
||||
try:
|
||||
resp = httpx.get(f"{self.base_url}/v1/hosts", timeout=2)
|
||||
if resp.status_code == 200:
|
||||
for host in resp.json().get("hosts", []):
|
||||
if host.get("host_id") == self.host_id and host.get("status") == "online":
|
||||
return
|
||||
except httpx.HTTPError:
|
||||
# Server not yet accepting requests, or a transient read error:
|
||||
# keep polling until the deadline rather than failing the boot.
|
||||
pass
|
||||
time.sleep(_POLL_INTERVAL_S)
|
||||
raise RuntimeError(f"host {self.host_id} not online within {_HOST_ONLINE_TIMEOUT_S}s")
|
||||
|
||||
# ── mock control (runner mode only) ──────────────────────
|
||||
|
||||
async def _mock_post(self, path: str, body: dict[str, object]) -> None:
|
||||
@@ -564,33 +427,6 @@ class BenchEnvironment:
|
||||
created.raise_for_status()
|
||||
return str(created.json()["id"])
|
||||
|
||||
async def create_hosted_session(self, agent_id: str) -> str:
|
||||
"""Create a host-bound session that fires ``host.launch_runner``.
|
||||
|
||||
The inline-launch ``POST /v1/sessions`` shape the Web UI's New Chat
|
||||
wizard sends: passing ``host_id`` + ``workspace`` makes the server bind a
|
||||
runner id and dispatch a launch frame to the host daemon, then return
|
||||
immediately (~tens of ms) WITHOUT waiting for the runner to connect.
|
||||
Returned without any readiness poll on purpose — the caller's first
|
||||
message then races the runner's boot, which is the cold path we measure.
|
||||
|
||||
:raises RuntimeError: If the env was not built with ``with_host=True``.
|
||||
"""
|
||||
assert self.client is not None
|
||||
if not self.with_host:
|
||||
raise RuntimeError("create_hosted_session requires with_host=True")
|
||||
created = await self.client.post(
|
||||
"/v1/sessions",
|
||||
json={
|
||||
"agent_id": agent_id,
|
||||
"host_id": self.host_id,
|
||||
"host_type": "external",
|
||||
"workspace": self.host_workspace,
|
||||
},
|
||||
)
|
||||
created.raise_for_status()
|
||||
return str(created.json()["id"])
|
||||
|
||||
async def seed_items(self, session_id: str, count: int) -> None:
|
||||
"""Append *count* history items over HTTP, with no runner or LLM.
|
||||
|
||||
@@ -620,22 +456,13 @@ class BenchEnvironment:
|
||||
# ── runner-mode session driving (phase 2) ────────────────
|
||||
|
||||
async def create_bound_session(self, agent_id: str) -> str:
|
||||
"""Create a session for *agent_id* and bind it to the boot runner."""
|
||||
return await self.create_session_bound_to(agent_id, self.runner_id)
|
||||
|
||||
async def create_session_bound_to(self, agent_id: str, runner_id: str) -> str:
|
||||
"""Create a session for *agent_id* and bind it to *runner_id*.
|
||||
|
||||
Binds a session to an already-online runner by patching its
|
||||
``runner_id`` — used by the warm journeys via :meth:`create_bound_session`
|
||||
to pin the boot runner.
|
||||
"""
|
||||
"""Create a session for *agent_id* and bind it to the runner."""
|
||||
assert self.client is not None
|
||||
if not self.with_runner:
|
||||
raise RuntimeError("create_session_bound_to requires with_runner=True")
|
||||
raise RuntimeError("create_bound_session requires with_runner=True")
|
||||
session_id = await self.create_session(agent_id)
|
||||
bound = await self.client.patch(
|
||||
f"/v1/sessions/{session_id}", json={"runner_id": runner_id}
|
||||
f"/v1/sessions/{session_id}", json={"runner_id": self.runner_id}
|
||||
)
|
||||
bound.raise_for_status()
|
||||
return session_id
|
||||
@@ -719,42 +546,26 @@ class BenchEnvironment:
|
||||
await asyncio.sleep(_POLL_INTERVAL_S)
|
||||
raise RuntimeError(f"session did not reach idle within {timeout}s ({session_id})")
|
||||
|
||||
async def _post_and_await_first_delta(
|
||||
self,
|
||||
session_id: str,
|
||||
text: str,
|
||||
*,
|
||||
wait_idle_first: bool,
|
||||
timeout: float = _TURN_TIMEOUT_S,
|
||||
async def time_to_first_delta(
|
||||
self, session_id: str, text: str, *, timeout: float = _TURN_TIMEOUT_S
|
||||
) -> None:
|
||||
"""Imitate the UI first-token path: attach SSE, then post, then await.
|
||||
"""Post a turn and return once the first output-text delta streams back.
|
||||
|
||||
The exact sequence the web client follows for a one-shot turn:
|
||||
subscribe to ``GET …/stream``, wait for the stream's ready heartbeat (the
|
||||
first SSE line — the server yields it right after registering the
|
||||
live-tail slot, so no event can be missed), POST the message, and return
|
||||
on the first response from the model — either a
|
||||
``response.output_text.delta`` (streamed text) or a
|
||||
``response.output_item.done`` (a completed output item, e.g. a tool call
|
||||
for harnesses that don't stream text deltas). This measures time to *any*
|
||||
first response, not just text. A terminal event before either arrives
|
||||
means the turn produced no response at all (a failure).
|
||||
The session SSE stream (``GET …/stream``) is separate from the message
|
||||
POST, so we subscribe first (as a concurrent task), post the turn, then
|
||||
return when the first ``response.output_text.delta`` event arrives. This
|
||||
times omnigent's streaming-pipeline overhead to first token — with the
|
||||
zero-latency mock there is no model latency in the number.
|
||||
|
||||
:param wait_idle_first: When ``True``, wait for the session to be ``idle``
|
||||
before subscribing so a prior turn's terminal event can't race this
|
||||
turn's response (warm-session TTFT). ``False`` for a fresh session whose
|
||||
first turn is the only one — the cold path, where the timed span must
|
||||
include runner launch + connect, so we must NOT poll it warm first.
|
||||
:raises RuntimeError: If not in runner mode, or no response / a terminal
|
||||
:raises RuntimeError: If not in runner mode, or no delta / a terminal
|
||||
event arrives within *timeout*.
|
||||
"""
|
||||
assert self.client is not None
|
||||
if not self.with_runner:
|
||||
raise RuntimeError("first-delta timing requires with_runner=True")
|
||||
raise RuntimeError("time_to_first_delta requires with_runner=True")
|
||||
|
||||
connected = asyncio.Event()
|
||||
first_delta = asyncio.Event()
|
||||
first_response = asyncio.Event()
|
||||
outcome: dict[str, str] = {}
|
||||
|
||||
async def _read_stream() -> None:
|
||||
@@ -763,9 +574,9 @@ class BenchEnvironment:
|
||||
"GET", f"/v1/sessions/{session_id}/stream", timeout=timeout
|
||||
) as resp:
|
||||
# Any first line means the SSE connection is live (the server
|
||||
# emits a ready heartbeat on connect). Signalling here lets us
|
||||
# post the turn only once subscribed — without a blind sleep
|
||||
# that would otherwise inflate the measured time-to-first-delta.
|
||||
# emits a heartbeat on connect). Signalling here lets us post
|
||||
# the turn only once subscribed — without a blind sleep that
|
||||
# would otherwise inflate the measured time-to-first-delta.
|
||||
connected.set()
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.startswith("event:"):
|
||||
@@ -774,9 +585,6 @@ class BenchEnvironment:
|
||||
if etype == "response.output_text.delta":
|
||||
first_delta.set()
|
||||
return
|
||||
if etype == "response.output_item.done":
|
||||
first_response.set()
|
||||
return
|
||||
if etype in _STREAM_TERMINAL_EVENTS:
|
||||
outcome["terminal"] = etype
|
||||
first_delta.set()
|
||||
@@ -786,16 +594,15 @@ class BenchEnvironment:
|
||||
connected.set()
|
||||
first_delta.set()
|
||||
|
||||
if wait_idle_first:
|
||||
# Warm path: ensure any prior turn has settled so the fresh
|
||||
# subscription's first terminal event can't be the previous turn
|
||||
# completing (which would otherwise race ahead of this turn's delta).
|
||||
await self._wait_idle(session_id, timeout=timeout)
|
||||
# Ensure any prior turn has settled so the fresh subscription's first
|
||||
# terminal event can't be the previous turn completing (which would
|
||||
# otherwise race ahead of this turn's delta).
|
||||
await self._wait_idle(session_id, timeout=timeout)
|
||||
|
||||
reader = asyncio.create_task(_read_stream())
|
||||
try:
|
||||
# Wait until the stream is actually connected (not a fixed sleep) so
|
||||
# the measured window is post → first response, not subscription setup.
|
||||
# the measured window is post → first delta, not subscription setup.
|
||||
await asyncio.wait_for(connected.wait(), timeout=timeout)
|
||||
posted = await self.client.post(
|
||||
f"/v1/sessions/{session_id}/events",
|
||||
@@ -805,71 +612,21 @@ class BenchEnvironment:
|
||||
},
|
||||
)
|
||||
posted.raise_for_status()
|
||||
# Return on the first response, whichever comes first: a streamed text
|
||||
# delta or a completed output item (e.g. a tool call for harnesses that
|
||||
# don't stream text).
|
||||
waiters = [
|
||||
asyncio.create_task(first_delta.wait()),
|
||||
asyncio.create_task(first_response.wait()),
|
||||
]
|
||||
done, pending = await asyncio.wait(
|
||||
waiters, timeout=timeout, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if not done:
|
||||
try:
|
||||
await asyncio.wait_for(first_delta.wait(), timeout=timeout)
|
||||
except TimeoutError as exc:
|
||||
raise RuntimeError(
|
||||
"no output_text.delta or output_item.done within "
|
||||
f"{timeout}s (session {session_id})"
|
||||
)
|
||||
f"no output_text.delta within {timeout}s (session {session_id})"
|
||||
) from exc
|
||||
if "error" in outcome:
|
||||
raise RuntimeError(f"stream error: {outcome['error']}")
|
||||
if "terminal" in outcome:
|
||||
raise RuntimeError(
|
||||
f"turn reached {outcome['terminal']} before any response "
|
||||
f"(session {session_id})"
|
||||
f"turn reached {outcome['terminal']} before any delta (session {session_id})"
|
||||
)
|
||||
finally:
|
||||
reader.cancel()
|
||||
|
||||
async def time_to_first_delta(
|
||||
self, session_id: str, text: str, *, timeout: float = _TURN_TIMEOUT_S
|
||||
) -> None:
|
||||
"""Post a turn on a WARM session and return on the first output delta.
|
||||
|
||||
Times omnigent's streaming-pipeline overhead to first token against an
|
||||
already-connected runner — with the zero-latency mock there is no model
|
||||
latency in the number. See :meth:`_post_and_await_first_delta`.
|
||||
"""
|
||||
await self._post_and_await_first_delta(
|
||||
session_id, text, wait_idle_first=True, timeout=timeout
|
||||
)
|
||||
|
||||
async def cold_start_first_delta(
|
||||
self, agent_id: str, text: str, *, timeout: float = _TURN_TIMEOUT_S
|
||||
) -> None:
|
||||
"""Time the full UI cold path: create → attach SSE → send → first token.
|
||||
|
||||
Reproduces exactly what the Web UI does for a brand-new host-bound
|
||||
session: create the session (which fires ``host.launch_runner`` and
|
||||
returns before the runner connects), then run the standard first-token
|
||||
sequence (attach the SSE stream, wait for its ready heartbeat, POST the
|
||||
first message, await the first ``response.output_text.delta``). Because
|
||||
the runner is still booting when the message posts, the server's
|
||||
connect-grace wait is on the timed path — so the measured span captures
|
||||
the real cold-start cost the ``session_cold_start`` journey exists for:
|
||||
host launch + runner boot + reverse-tunnel connect + first-token
|
||||
pipeline. No pre-warm and no ``GET /session`` status polling — the SSE
|
||||
first-delta signal is the same one the UI renders on.
|
||||
|
||||
:raises RuntimeError: If not host-backed, or no delta / a terminal event
|
||||
arrives within *timeout*.
|
||||
"""
|
||||
session_id = await self.create_hosted_session(agent_id)
|
||||
await self._post_and_await_first_delta(
|
||||
session_id, text, wait_idle_first=False, timeout=timeout
|
||||
)
|
||||
|
||||
async def drive_and_interrupt(
|
||||
self, session_id: str, *, timeout: float = _TURN_TIMEOUT_S
|
||||
) -> None:
|
||||
|
||||
@@ -19,14 +19,6 @@ v1 journeys are pure HTTP/API (server + DB, no runner, no LLM):
|
||||
``read_runner_file`` needs a runner but no LLM turn: it plants a file in the
|
||||
runner environment (setup) and times the server → runner filesystem read proxy.
|
||||
|
||||
Full-turn journeys (``needs_runner=True``) drive a real turn through the runner
|
||||
+ mock LLM. ``session_cold_start`` (``needs_host=True``) measures the real UI
|
||||
new-conversation cold path: it spawns a host daemon once, then per iteration
|
||||
creates a host-bound session (which fires ``host.launch_runner``), attaches the
|
||||
SSE stream, sends the first message, and times to the first output-text delta —
|
||||
so the span includes the on-demand runner launch + reverse-tunnel handshake the
|
||||
UI's first message races, exactly as a real new chat pays it.
|
||||
|
||||
The framework (``Journey`` + the two runners) is harness-agnostic and reused
|
||||
verbatim by phase-2 full-turn journeys.
|
||||
"""
|
||||
@@ -77,10 +69,6 @@ class Journey:
|
||||
:param needs_runner: Whether this journey drives a full agent turn and so
|
||||
requires ``BenchEnvironment(with_runner=True)`` (mock LLM + runner).
|
||||
HTTP/DB journeys leave this ``False``.
|
||||
:param needs_host: Whether this journey needs a real host daemon
|
||||
(``BenchEnvironment(with_host=True)``) so a host-bound session-create
|
||||
fires ``host.launch_runner`` and the first message races the runner's
|
||||
boot. Implies ``needs_runner``. Only ``session_cold_start`` sets this.
|
||||
:param max_iterations: Upper bound on latency iterations for this journey,
|
||||
clamping ``--iterations`` down (never up). Full-turn journeys cost ~1s+
|
||||
per op, so 100+ iterations would blow the CI time budget; they cap at a
|
||||
@@ -96,7 +84,6 @@ class Journey:
|
||||
teardown: Callable[[BenchEnvironment, JourneyContext], Awaitable[None]] | None = None
|
||||
concurrency_safe: bool = False
|
||||
needs_runner: bool = False
|
||||
needs_host: bool = False
|
||||
max_iterations: int | None = None
|
||||
description: str = ""
|
||||
|
||||
@@ -374,17 +361,6 @@ async def _setup_turn_agent(env: BenchEnvironment, *, stream: bool = False) -> s
|
||||
return await env.agent_id(name)
|
||||
|
||||
|
||||
async def _setup_cold_start_agent(env: BenchEnvironment) -> str:
|
||||
"""Register a streaming-reply agent for the cold-start journey; return its id.
|
||||
|
||||
No session and no warm-up turn — the cold-start measure creates a fresh
|
||||
host-bound session each iteration. The reply streams deltas so the measured
|
||||
op can return on the first ``response.output_text.delta`` (the UI's
|
||||
first-token signal).
|
||||
"""
|
||||
return await _setup_turn_agent(env, stream=True)
|
||||
|
||||
|
||||
async def _setup_warm_session(env: BenchEnvironment) -> str:
|
||||
"""Create+bind a session and drive one warm-up turn; return the session id.
|
||||
|
||||
@@ -420,31 +396,9 @@ async def _setup_interrupt_session(env: BenchEnvironment) -> str:
|
||||
|
||||
|
||||
async def _measure_session_cold_start(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
"""Time the real UI cold path: create host-bound session → first token.
|
||||
|
||||
Faithfully imitates the Web UI's New Chat flow on a fresh session (see
|
||||
``BenchEnvironment.cold_start_first_delta``): create a host-bound session
|
||||
(which fires ``host.launch_runner`` at the host daemon and returns before
|
||||
the runner connects), attach the SSE stream, wait for its ready heartbeat,
|
||||
POST the first message, and return on the first response.
|
||||
|
||||
Because the message posts while the runner is still booting, the server's
|
||||
connect-grace wait is on the timed path — so the measured span captures the
|
||||
true new-conversation cost: host launch + runner boot + reverse-tunnel
|
||||
connect + first-token pipeline.
|
||||
|
||||
Each iteration is its own fresh session with its own host-launched runner.
|
||||
The server never stops an external-host runner on idle (only on an explicit
|
||||
stop/delete, neither of which the UI first-message path does), so each
|
||||
iteration's runner stays connected until the daemon is SIGTERM'd at env
|
||||
teardown, which reaps them together. That is bounded — ``_RUNNER_MAX_ITERATIONS``
|
||||
(+ warmups) runners at most, all cleaned up at the end — so we deliberately
|
||||
skip per-iteration teardown: stopping the runner would add a
|
||||
stop-round-trip to a journey whose whole point is to time the fresh-launch
|
||||
cost, and would not reflect what a real first message does.
|
||||
"""
|
||||
agent_id = cast(str, ctx) # _setup_turn_agent (stream=True)
|
||||
await env.cold_start_first_delta(agent_id, _TURN_PROMPT)
|
||||
agent_id = cast(str, ctx) # _setup_turn_agent
|
||||
session_id = await env.create_bound_session(agent_id)
|
||||
await env.drive_turn(session_id, _TURN_PROMPT)
|
||||
|
||||
|
||||
async def _measure_warm_turn(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
@@ -545,12 +499,10 @@ ALL_JOURNEYS: dict[str, Journey] = {
|
||||
name="session_cold_start",
|
||||
kind="latency",
|
||||
measure=_measure_session_cold_start,
|
||||
setup=_setup_cold_start_agent,
|
||||
setup=_setup_turn_agent,
|
||||
needs_runner=True,
|
||||
needs_host=True,
|
||||
max_iterations=_RUNNER_MAX_ITERATIONS,
|
||||
description="Create a host-bound session (fires host.launch_runner) then "
|
||||
"time create → attach SSE → send → first token — the real UI cold path.",
|
||||
description="Create+bind a fresh session and drive its first turn to idle.",
|
||||
),
|
||||
Journey(
|
||||
name="warm_turn",
|
||||
|
||||
@@ -64,8 +64,6 @@ def _backend_of(database_uri: str | None) -> str:
|
||||
return "sqlite"
|
||||
if database_uri.startswith("postgres"):
|
||||
return "postgres"
|
||||
if database_uri.startswith("mysql"):
|
||||
return "mysql"
|
||||
return "other"
|
||||
|
||||
|
||||
@@ -124,16 +122,10 @@ async def run_benchmark(args: argparse.Namespace) -> tuple[dict[str, object], bo
|
||||
# Any full-turn journey needs the runner + mock LLM. A full env is a
|
||||
# superset — HTTP journeys still run against it — so a mixed selection just
|
||||
# boots with_runner=True. The harness label reflects what drove the turns.
|
||||
# A host-backed journey (session_cold_start) additionally needs a host
|
||||
# daemon; with_host is a further superset (it implies with_runner) so a
|
||||
# mixed selection that includes it boots the host too.
|
||||
with_runner = any(j.needs_runner for j in journeys)
|
||||
with_host = any(j.needs_host for j in journeys)
|
||||
harness = _RUNNER_HARNESS if with_runner else _HTTP_HARNESS
|
||||
|
||||
async with BenchEnvironment(
|
||||
with_runner=with_runner, with_host=with_host, database_uri=args.database_uri
|
||||
) as env:
|
||||
async with BenchEnvironment(with_runner=with_runner, database_uri=args.database_uri) as env:
|
||||
for journey in journeys:
|
||||
console.print(f"\n[bold]Benchmarking[/bold] {journey.name} [dim]({backend})[/dim]")
|
||||
kind, results = await _run_journey(journey, env, args)
|
||||
@@ -141,11 +133,6 @@ async def run_benchmark(args: argparse.Namespace) -> tuple[dict[str, object], bo
|
||||
block = aggregate(results)
|
||||
block["kind"] = kind
|
||||
block["backend"] = backend
|
||||
# Hardcoded per-journey mapping: HTTP journeys are False, full-turn
|
||||
# journeys True. Sourced from the journey itself, not the run-level
|
||||
# env, so it stays correct in a mixed selection (where with_runner
|
||||
# is True for the whole run because *some* journey needs it).
|
||||
block["needs_runner"] = journey.needs_runner
|
||||
journey_results[journey.name] = block
|
||||
if not check_thresholds(
|
||||
results,
|
||||
@@ -191,10 +178,10 @@ def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
"--database-uri",
|
||||
default=None,
|
||||
metavar="URI",
|
||||
help="DB the server boots against — a pre-seeded SQLite file, a "
|
||||
"postgresql+psycopg://… instance, or a mysql+mysqldb://… instance "
|
||||
"(see seed.py). Default: a fresh throwaway SQLite DB (empty — "
|
||||
"best-case numbers). The report's `backend` field is derived from this.",
|
||||
help="DB the server boots against — a pre-seeded SQLite file or a "
|
||||
"postgresql+psycopg://… instance (see seed.py). Default: a fresh "
|
||||
"throwaway SQLite DB (empty — best-case numbers). The report's "
|
||||
"`backend` field is derived from this.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--iterations",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"schema_version": 1,
|
||||
"generated_at": "2026-07-08T18:30:00+00:00",
|
||||
"git_sha": "0000000000000000000000000000000000000000",
|
||||
"git_branch": "main",
|
||||
@@ -66,8 +66,7 @@
|
||||
"avg_rps": 162.7669557884957
|
||||
},
|
||||
"kind": "latency",
|
||||
"backend": "sqlite",
|
||||
"needs_runner": false
|
||||
"backend": "sqlite"
|
||||
},
|
||||
"create_session": {
|
||||
"runs": [
|
||||
@@ -116,8 +115,7 @@
|
||||
"avg_rps": 40.548671853654
|
||||
},
|
||||
"kind": "latency",
|
||||
"backend": "sqlite",
|
||||
"needs_runner": false
|
||||
"backend": "sqlite"
|
||||
},
|
||||
"get_session": {
|
||||
"runs": [
|
||||
@@ -166,8 +164,7 @@
|
||||
"avg_rps": 199.59858100569238
|
||||
},
|
||||
"kind": "latency",
|
||||
"backend": "sqlite",
|
||||
"needs_runner": false
|
||||
"backend": "sqlite"
|
||||
},
|
||||
"load_conversation_history": {
|
||||
"runs": [
|
||||
@@ -216,8 +213,7 @@
|
||||
"avg_rps": 503.8957501013986
|
||||
},
|
||||
"kind": "latency",
|
||||
"backend": "sqlite",
|
||||
"needs_runner": false
|
||||
"backend": "sqlite"
|
||||
},
|
||||
"search_sessions": {
|
||||
"runs": [
|
||||
@@ -266,8 +262,7 @@
|
||||
"avg_rps": 12.317410535217997
|
||||
},
|
||||
"kind": "latency",
|
||||
"backend": "sqlite",
|
||||
"needs_runner": false
|
||||
"backend": "sqlite"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import platform
|
||||
import subprocess
|
||||
|
||||
# Incremented on any breaking change to the report document shape below.
|
||||
SCHEMA_VERSION = 2
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def _git(*args: str) -> str:
|
||||
|
||||
Generated
-92
@@ -2,15 +2,6 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -92,16 +83,6 @@ version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.0"
|
||||
@@ -189,31 +170,6 @@ dependencies = [
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
@@ -319,19 +275,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "globset"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"bstr",
|
||||
"log",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -371,22 +314,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ignore"
|
||||
version = "0.4.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"globset",
|
||||
"log",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"same-file",
|
||||
"walkdir",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -599,7 +526,6 @@ dependencies = [
|
||||
"clap",
|
||||
"crossterm",
|
||||
"if-addrs",
|
||||
"ignore",
|
||||
"libc",
|
||||
"notify",
|
||||
"notify-debouncer-full",
|
||||
@@ -608,7 +534,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"toml",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -700,23 +625,6 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.44"
|
||||
|
||||
@@ -15,10 +15,8 @@ clap = { version = "4", features = ["derive"] }
|
||||
crossterm = "0.28"
|
||||
ratatui = "0.29"
|
||||
ansi-to-tui = "7"
|
||||
unicode-width = "0.2"
|
||||
notify = "8"
|
||||
notify-debouncer-full = "0.5"
|
||||
ignore = "0.4"
|
||||
libc = "0.2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
+6
-19
@@ -20,11 +20,8 @@ replaces the three-terminal local dev flow (`omnigent server`, `omnigent host`,
|
||||
- **supervises** the backend server, the host daemon, and the Vite frontend,
|
||||
restarting any that crash (with backoff);
|
||||
- **reloads the backend** (server → host) when you edit `omnigent/**/*.py`;
|
||||
gitignored files under `omnigent/` (e.g. the build-time `_build_info.py`) are
|
||||
skipped so generated churn doesn't reload; the frontend self-reloads through
|
||||
Vite HMR;
|
||||
- gives you **per-process log panes** plus a combined view, each a `less`-style
|
||||
pager with wrap and search (see [Keys](#keys)).
|
||||
the frontend self-reloads through Vite HMR;
|
||||
- gives you **scrollable per-process log panes** plus a combined view.
|
||||
|
||||
## Build & run
|
||||
|
||||
@@ -45,8 +42,8 @@ Run it from anywhere inside the checkout — it walks up to the repo root
|
||||
|
||||
| Process | Command | Notes |
|
||||
|---|---|---|
|
||||
| server | `uv run omnigent --log-to-stderr server --host 127.0.0.1 --port <p> --database-uri … --artifact-location …` | Waited on via `GET /health`. |
|
||||
| host | `uv run omnigent --log-to-stderr host --server http://127.0.0.1:<p>` | Started once the server is healthy. |
|
||||
| server | `uv run omnigent server --host 127.0.0.1 --port <p> --database-uri … --artifact-location …` | Waited on via `GET /health`. |
|
||||
| host | `uv run omnigent host --server http://127.0.0.1:<p>` | Started once the server is healthy. |
|
||||
| vite | `npm run dev -- --host <host> --port <p> --strictPort` (cwd `web/`) | `OMNIGENT_URL` points its proxy at the pod's server. |
|
||||
|
||||
Before Vite starts (and on a manual Vite restart), omnidev runs `npm install`
|
||||
@@ -88,7 +85,6 @@ canonical checkout path. Per-process logs are written through to
|
||||
--pod-dir <PATH> Use a specific pod dir instead of the per-repo default
|
||||
--no-vite Backend + host only (no frontend)
|
||||
--clean Wipe the pod dir before starting
|
||||
--debug Log each watched file change and whether it reloads
|
||||
```
|
||||
|
||||
`--vite-host 0.0.0.0` exposes the Vite dev server on all interfaces for device
|
||||
@@ -118,21 +114,12 @@ not auto-trusted (add those to `OMNIGENT_WS_ALLOWED_ORIGINS` yourself).
|
||||
|
||||
## Keys
|
||||
|
||||
The log pane is a `less`-style pager, so the movement and search keys should
|
||||
feel familiar.
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `1` / `2` / `3` / `0` | Focus server / host / vite / combined pane |
|
||||
| `Tab` | Cycle panes |
|
||||
| `j` / `k` (or `↓` / `↑`) | Scroll one line |
|
||||
| `f` / `Space` / `PgDn` (or `b` / `PgUp`) | Page forward / back one window |
|
||||
| `d` / `u` | Half-page forward / back |
|
||||
| `g` / `G` | Jump to top / bottom (bottom re-follows the tail) |
|
||||
| `F` | Toggle follow-tail (like `less +F`) |
|
||||
| `w` | Toggle line wrap (on by default) |
|
||||
| `/` `?` | Search forward / back — type, `Enter` to jump, `Esc` to cancel |
|
||||
| `n` / `N` | Next / previous match |
|
||||
| `↑` `↓` `PgUp` `PgDn` | Scroll (detaches from tail) |
|
||||
| `f` | Toggle follow-tail |
|
||||
| `r` | Restart the focused process (server/host restart as a pair) |
|
||||
| `R` | Restart the backend (server then host) |
|
||||
| `c` | Clear the focused pane |
|
||||
|
||||
+1
-12
@@ -77,11 +77,6 @@ struct RunArgs {
|
||||
/// Wipe the pod directory before starting.
|
||||
#[arg(long)]
|
||||
clean: bool,
|
||||
|
||||
/// Log every observed file change and whether it triggers a backend reload
|
||||
/// (with the skip reason otherwise).
|
||||
#[arg(long)]
|
||||
debug: bool,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
@@ -193,13 +188,7 @@ async fn run_supervisor(args: RunArgs) -> Result<()> {
|
||||
|
||||
// File watcher: Python changes -> Reload commands. Keep the debouncer alive
|
||||
// for the whole session.
|
||||
let _watcher = watcher::spawn(
|
||||
&pod.repo_root,
|
||||
&pod.omnigent_dir(),
|
||||
shared.clone(),
|
||||
args.debug,
|
||||
cmd_tx.clone(),
|
||||
)?;
|
||||
let _watcher = watcher::spawn(&pod.omnigent_dir(), cmd_tx.clone())?;
|
||||
|
||||
// Supervisor runs on the tokio runtime; the TUI drives it via cmd_tx.
|
||||
let supervisor = Supervisor::new(
|
||||
|
||||
@@ -5,34 +5,22 @@ use std::path::PathBuf;
|
||||
use crate::pod::Pod;
|
||||
|
||||
/// A resolved command line + working dir for one process. Env is applied by the
|
||||
/// supervisor from `Pod::env()`, with per-process additions from `extra_env`.
|
||||
/// supervisor from `Pod::env()`, so it is not duplicated here.
|
||||
pub struct ProcSpec {
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub cwd: PathBuf,
|
||||
pub extra_env: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl ProcSpec {
|
||||
fn omnigent_log_env() -> Vec<(String, String)> {
|
||||
// Child stderr is a pipe that omnidev reads into its process panes.
|
||||
// Let Omnigent's process logger mirror to that pipe despite it not
|
||||
// being a terminal, and force ANSI colors because omnidev parses them.
|
||||
vec![
|
||||
("OMNIGENT_LOG_TTY_FD".into(), "2".into()),
|
||||
("OMNIGENT_LOG_FORCE_COLOR".into(), "1".into()),
|
||||
]
|
||||
}
|
||||
|
||||
/// `uv run omnigent --log-to-stderr server --host 127.0.0.1 --port <p>
|
||||
/// --database-uri <db> --artifact-location <dir>`, from the repo root.
|
||||
/// `uv run omnigent server --host 127.0.0.1 --port <p> --database-uri <db>
|
||||
/// --artifact-location <dir>`, from the repo root.
|
||||
pub fn server(pod: &Pod) -> ProcSpec {
|
||||
ProcSpec {
|
||||
program: "uv".into(),
|
||||
args: vec![
|
||||
"run".into(),
|
||||
"omnigent".into(),
|
||||
"--log-to-stderr".into(),
|
||||
"server".into(),
|
||||
"--host".into(),
|
||||
"127.0.0.1".into(),
|
||||
@@ -44,25 +32,21 @@ impl ProcSpec {
|
||||
pod.artifacts_dir().display().to_string(),
|
||||
],
|
||||
cwd: pod.repo_root.clone(),
|
||||
extra_env: Self::omnigent_log_env(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `uv run omnigent --log-to-stderr host --server http://127.0.0.1:<p>`,
|
||||
/// from the repo root.
|
||||
/// `uv run omnigent host --server http://127.0.0.1:<p>`, from the repo root.
|
||||
pub fn host(pod: &Pod) -> ProcSpec {
|
||||
ProcSpec {
|
||||
program: "uv".into(),
|
||||
args: vec![
|
||||
"run".into(),
|
||||
"omnigent".into(),
|
||||
"--log-to-stderr".into(),
|
||||
"host".into(),
|
||||
"--server".into(),
|
||||
pod.server_url(),
|
||||
],
|
||||
cwd: pod.repo_root.clone(),
|
||||
extra_env: Self::omnigent_log_env(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +67,6 @@ impl ProcSpec {
|
||||
"http".into(),
|
||||
],
|
||||
cwd: pod.web_dir(),
|
||||
extra_env: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +86,6 @@ impl ProcSpec {
|
||||
"--strictPort".into(),
|
||||
],
|
||||
cwd: pod.web_dir(),
|
||||
extra_env: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,45 +117,6 @@ mod tests {
|
||||
assert_eq!(pod.server_url(), "http://127.0.0.1:19191");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omnigent_processes_mirror_logs_to_omnidev_pipe() {
|
||||
let repo = tempdir();
|
||||
let pod_dir = tempdir();
|
||||
let pod = Pod::create(
|
||||
repo,
|
||||
pod_dir,
|
||||
Ports {
|
||||
server: 19191,
|
||||
vite: 19292,
|
||||
},
|
||||
"127.0.0.1".into(),
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
for spec in [ProcSpec::server(&pod), ProcSpec::host(&pod)] {
|
||||
assert!(
|
||||
spec.args.iter().any(|arg| arg == "--log-to-stderr"),
|
||||
"omnigent command should request stderr logging: {:?}",
|
||||
spec.args
|
||||
);
|
||||
assert_eq!(
|
||||
spec.extra_env
|
||||
.iter()
|
||||
.find(|(key, _)| key == "OMNIGENT_LOG_TTY_FD")
|
||||
.map(|(_, value)| value.as_str()),
|
||||
Some("2")
|
||||
);
|
||||
assert_eq!(
|
||||
spec.extra_env
|
||||
.iter()
|
||||
.find(|(key, _)| key == "OMNIGENT_LOG_FORCE_COLOR")
|
||||
.map(|(_, value)| value.as_str()),
|
||||
Some("1")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn tempdir() -> std::path::PathBuf {
|
||||
let unique = format!(
|
||||
"omnidev-process-test-{}-{}",
|
||||
|
||||
@@ -211,7 +211,6 @@ impl Supervisor {
|
||||
cmd.args(&spec.args)
|
||||
.current_dir(&spec.cwd)
|
||||
.envs(self.env.iter().cloned())
|
||||
.envs(spec.extra_env.iter().cloned())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
@@ -293,7 +292,6 @@ impl Supervisor {
|
||||
cmd.args(&spec.args)
|
||||
.current_dir(&spec.cwd)
|
||||
.envs(self.env.iter().cloned())
|
||||
.envs(spec.extra_env.iter().cloned())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
+10
-481
@@ -3,7 +3,6 @@
|
||||
|
||||
mod render;
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::io::{self, Stdout};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
@@ -31,35 +30,6 @@ pub enum View {
|
||||
All,
|
||||
}
|
||||
|
||||
/// Search direction. `Fwd` scans toward the tail (newer lines), `Back` toward
|
||||
/// the head — matching `less`'s `/` and `?`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Dir {
|
||||
Fwd,
|
||||
Back,
|
||||
}
|
||||
|
||||
impl Dir {
|
||||
fn flip(self) -> Dir {
|
||||
match self {
|
||||
Dir::Fwd => Dir::Back,
|
||||
Dir::Back => Dir::Fwd,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A committed search: the query and the direction it was entered with.
|
||||
pub struct Search {
|
||||
pub query: String,
|
||||
pub dir: Dir,
|
||||
}
|
||||
|
||||
/// The line-editor state while the user is typing a `/` or `?` query.
|
||||
pub struct InputMode {
|
||||
pub dir: Dir,
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
impl View {
|
||||
fn proc(self) -> Option<ProcId> {
|
||||
match self {
|
||||
@@ -76,23 +46,9 @@ pub struct App {
|
||||
shared: Arc<Mutex<Shared>>,
|
||||
cmds: mpsc::UnboundedSender<Cmd>,
|
||||
view: View,
|
||||
/// Display rows scrolled up from the bottom; 0 == pinned to tail. Counted in
|
||||
/// *rendered rows*, so it stays correct whether or not lines wrap.
|
||||
/// Lines scrolled up from the bottom; 0 == pinned to tail.
|
||||
scroll_back: usize,
|
||||
follow: bool,
|
||||
/// Wrap long lines to the next row (default) vs. clip them at the edge.
|
||||
wrap: bool,
|
||||
/// Body size in rows/cols, refreshed by the renderer each frame so key
|
||||
/// handling can page by a full/half window and lay out wraps for search.
|
||||
/// Seeded so keys pressed before the first draw still behave.
|
||||
viewport_h: Cell<usize>,
|
||||
viewport_w: Cell<usize>,
|
||||
/// The last committed search, if any (drives `n`/`N` and highlighting).
|
||||
search: Option<Search>,
|
||||
/// Logical line index of the match `n`/`N` last jumped to, for anchoring.
|
||||
current_match: Option<usize>,
|
||||
/// Set while the user is typing a query; steals keys from command mode.
|
||||
input: Option<InputMode>,
|
||||
should_quit: bool,
|
||||
}
|
||||
|
||||
@@ -105,12 +61,6 @@ impl App {
|
||||
view: View::All,
|
||||
scroll_back: 0,
|
||||
follow: true,
|
||||
wrap: true,
|
||||
viewport_h: Cell::new(20),
|
||||
viewport_w: Cell::new(80),
|
||||
search: None,
|
||||
current_match: None,
|
||||
input: None,
|
||||
should_quit: false,
|
||||
}
|
||||
}
|
||||
@@ -148,21 +98,9 @@ impl App {
|
||||
if key.kind != KeyEventKind::Press {
|
||||
return;
|
||||
}
|
||||
// Ctrl-C always quits, even mid-search.
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
self.should_quit = true;
|
||||
return;
|
||||
}
|
||||
// While typing a query, keys build/commit/cancel it instead of running
|
||||
// commands.
|
||||
if self.input.is_some() {
|
||||
self.on_key_input(key);
|
||||
return;
|
||||
}
|
||||
|
||||
let window = self.viewport_h.get().max(1);
|
||||
let half = (window / 2).max(1);
|
||||
let page = 20;
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Char('c'), KeyModifiers::CONTROL) => self.should_quit = true,
|
||||
(KeyCode::Char('q'), _) => self.should_quit = true,
|
||||
|
||||
(KeyCode::Char('1'), _) => self.set_view(View::Server),
|
||||
@@ -171,33 +109,17 @@ impl App {
|
||||
(KeyCode::Char('0'), _) => self.set_view(View::All),
|
||||
(KeyCode::Tab, _) => self.cycle_view(),
|
||||
|
||||
// Pager movement — full `less` semantics.
|
||||
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => self.scroll_down(1),
|
||||
(KeyCode::Char('k'), _) | (KeyCode::Up, _) => self.scroll_up(1),
|
||||
(KeyCode::Char('f'), _) | (KeyCode::Char(' '), _) | (KeyCode::PageDown, _) => {
|
||||
self.scroll_down(window)
|
||||
}
|
||||
(KeyCode::Char('b'), _) | (KeyCode::PageUp, _) => self.scroll_up(window),
|
||||
(KeyCode::Char('d'), _) => self.scroll_down(half),
|
||||
(KeyCode::Char('u'), _) => self.scroll_up(half),
|
||||
(KeyCode::Char('g'), _) | (KeyCode::Home, _) => self.scroll_to_top(),
|
||||
(KeyCode::Char('G'), _) | (KeyCode::End, _) => self.scroll_to_bottom(),
|
||||
(KeyCode::Up, _) => self.scroll(1),
|
||||
(KeyCode::Down, _) => self.scroll_down(1),
|
||||
(KeyCode::PageUp, _) => self.scroll(page),
|
||||
(KeyCode::PageDown, _) => self.scroll_down(page),
|
||||
|
||||
// `less +F`: capital F toggles tail-follow.
|
||||
(KeyCode::Char('F'), _) => {
|
||||
(KeyCode::Char('f'), _) => {
|
||||
self.follow = !self.follow;
|
||||
if self.follow {
|
||||
self.scroll_back = 0;
|
||||
}
|
||||
}
|
||||
(KeyCode::Char('w'), _) => self.toggle_wrap(),
|
||||
|
||||
// Search.
|
||||
(KeyCode::Char('/'), _) => self.begin_search(Dir::Fwd),
|
||||
(KeyCode::Char('?'), _) => self.begin_search(Dir::Back),
|
||||
(KeyCode::Char('n'), _) => self.repeat_search(false),
|
||||
(KeyCode::Char('N'), _) => self.repeat_search(true),
|
||||
|
||||
(KeyCode::Char('r'), _) => {
|
||||
if let Some(id) = self.view.proc() {
|
||||
let _ = self.cmds.send(Cmd::Restart(id));
|
||||
@@ -213,43 +135,9 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a key while a `/` or `?` query is being typed.
|
||||
fn on_key_input(&mut self, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Enter => {
|
||||
let input = self.input.take().unwrap();
|
||||
if !input.query.is_empty() {
|
||||
self.search = Some(Search {
|
||||
query: input.query,
|
||||
dir: input.dir,
|
||||
});
|
||||
self.current_match = None;
|
||||
self.run_search(input.dir, true);
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => self.input = None,
|
||||
KeyCode::Backspace => {
|
||||
let done = {
|
||||
let input = self.input.as_mut().unwrap();
|
||||
input.query.pop();
|
||||
input.query.is_empty()
|
||||
};
|
||||
if done {
|
||||
self.input = None;
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
self.input.as_mut().unwrap().query.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_view(&mut self, v: View) {
|
||||
self.view = v;
|
||||
self.scroll_back = 0;
|
||||
// Match indices are per-view; drop the anchor on switch.
|
||||
self.current_match = None;
|
||||
}
|
||||
|
||||
fn cycle_view(&mut self) {
|
||||
@@ -260,10 +148,9 @@ impl App {
|
||||
View::Vite => View::All,
|
||||
};
|
||||
self.scroll_back = 0;
|
||||
self.current_match = None;
|
||||
}
|
||||
|
||||
fn scroll_up(&mut self, n: usize) {
|
||||
fn scroll(&mut self, n: usize) {
|
||||
// Scrolling up detaches from the tail.
|
||||
self.follow = false;
|
||||
self.scroll_back = self.scroll_back.saturating_add(n);
|
||||
@@ -276,147 +163,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_to_top(&mut self) {
|
||||
self.follow = false;
|
||||
let lines = self.display_lines();
|
||||
let counts = self.row_counts(&lines);
|
||||
let total: usize = counts.iter().sum();
|
||||
let height = self.viewport_h.get().max(1);
|
||||
self.scroll_back = total.saturating_sub(height);
|
||||
}
|
||||
|
||||
fn scroll_to_bottom(&mut self) {
|
||||
self.scroll_back = 0;
|
||||
self.follow = true;
|
||||
}
|
||||
|
||||
fn toggle_wrap(&mut self) {
|
||||
self.wrap = !self.wrap;
|
||||
// Row counts change with wrap; re-anchor on the matched line if any,
|
||||
// otherwise drop to the tail so we land somewhere sane.
|
||||
match self.current_match {
|
||||
Some(idx) => {
|
||||
let lines = self.display_lines();
|
||||
self.jump_to_logical(idx, &lines);
|
||||
}
|
||||
None => self.scroll_to_bottom(),
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_search(&mut self, dir: Dir) {
|
||||
self.input = Some(InputMode {
|
||||
dir,
|
||||
query: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
/// `n` repeats the committed search in its direction; `N` (opposite=true)
|
||||
/// reverses it.
|
||||
fn repeat_search(&mut self, opposite: bool) {
|
||||
let Some(search) = self.search.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let dir = if opposite {
|
||||
search.dir.flip()
|
||||
} else {
|
||||
search.dir
|
||||
};
|
||||
self.run_search(dir, false);
|
||||
}
|
||||
|
||||
/// Scan for the next match and jump to it. `fresh` anchors from the current
|
||||
/// viewport; otherwise it steps off the last matched line.
|
||||
fn run_search(&mut self, dir: Dir, fresh: bool) {
|
||||
let Some(query) = self.search.as_ref().map(|s| s.query.to_ascii_lowercase()) else {
|
||||
return;
|
||||
};
|
||||
let lines = self.display_lines();
|
||||
let n = lines.len();
|
||||
if n == 0 || query.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let start = if fresh {
|
||||
self.anchor(&lines, dir)
|
||||
} else {
|
||||
match self.current_match {
|
||||
Some(m) => match dir {
|
||||
Dir::Fwd => (m + 1) % n,
|
||||
Dir::Back => (m + n - 1) % n,
|
||||
},
|
||||
None => self.anchor(&lines, dir),
|
||||
}
|
||||
};
|
||||
|
||||
// Scan every line once, wrapping around the ends.
|
||||
for k in 0..n {
|
||||
let i = match dir {
|
||||
Dir::Fwd => (start + k) % n,
|
||||
Dir::Back => (start + n - (k % n)) % n,
|
||||
};
|
||||
if lines[i].to_ascii_lowercase().contains(&query) {
|
||||
self.current_match = Some(i);
|
||||
self.jump_to_logical(i, &lines);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Displayed text (ANSI stripped, `[label]` prefix included in the combined
|
||||
/// view) for every logical line of the focused channel — the exact text the
|
||||
/// renderer shows, so search offsets and wrap counts line up.
|
||||
fn display_lines(&self) -> Vec<String> {
|
||||
let all_view = self.view == View::All;
|
||||
let s = self.shared.lock().unwrap();
|
||||
let iter: Box<dyn Iterator<Item = &String>> = match self.view {
|
||||
View::Server => Box::new(s.buf(ProcId::Server).iter()),
|
||||
View::Host => Box::new(s.buf(ProcId::Host).iter()),
|
||||
View::Vite => Box::new(s.buf(ProcId::Vite).iter()),
|
||||
View::All => Box::new(s.all.iter()),
|
||||
};
|
||||
iter.map(|l| render::display_text(l, all_view)).collect()
|
||||
}
|
||||
|
||||
/// Per-line display-row counts at the current width/wrap.
|
||||
fn row_counts(&self, lines: &[String]) -> Vec<usize> {
|
||||
let width = self.viewport_w.get();
|
||||
lines
|
||||
.iter()
|
||||
.map(|t| render::row_count(t, width, self.wrap))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The logical line a fresh search should scan from: the top visible line
|
||||
/// going forward, the bottom visible line going back.
|
||||
fn anchor(&self, lines: &[String], dir: Dir) -> usize {
|
||||
let counts = self.row_counts(lines);
|
||||
let total: usize = counts.iter().sum();
|
||||
let height = self.viewport_h.get().max(1);
|
||||
let back = self.scroll_back.min(total.saturating_sub(height));
|
||||
let end = total.saturating_sub(back); // one past the bottom visible row
|
||||
let top_row = end.saturating_sub(height);
|
||||
match dir {
|
||||
Dir::Fwd => line_at_row(&counts, top_row),
|
||||
Dir::Back => line_at_row(&counts, end.saturating_sub(1)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll so logical line `idx`'s first display row sits at the top of the
|
||||
/// viewport (clamped so we never scroll past the tail).
|
||||
fn jump_to_logical(&mut self, idx: usize, lines: &[String]) {
|
||||
let counts = self.row_counts(lines);
|
||||
if idx >= counts.len() {
|
||||
return;
|
||||
}
|
||||
let height = self.viewport_h.get().max(1);
|
||||
let below: usize = counts[idx + 1..].iter().sum();
|
||||
let own = counts[idx];
|
||||
let total: usize = counts.iter().sum();
|
||||
let max_back = total.saturating_sub(height);
|
||||
self.scroll_back = (own + below).saturating_sub(height).min(max_back);
|
||||
self.follow = false;
|
||||
}
|
||||
|
||||
fn clear_current(&mut self) {
|
||||
let mut s = self.shared.lock().unwrap();
|
||||
match self.view {
|
||||
@@ -426,10 +172,9 @@ impl App {
|
||||
View::All => s.all.clear(),
|
||||
}
|
||||
self.scroll_back = 0;
|
||||
self.current_match = None;
|
||||
}
|
||||
|
||||
/// Total logical line count of the focused channel, for the status readout.
|
||||
/// Total line count of the focused channel, for the status readout.
|
||||
pub fn line_count(&self) -> usize {
|
||||
let s = self.shared.lock().unwrap();
|
||||
match self.view {
|
||||
@@ -439,53 +184,6 @@ impl App {
|
||||
View::All => s.all.iter().count(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The committed query, ASCII-lowercased, for the renderer's highlight
|
||||
/// pass. `None` when no search is active.
|
||||
pub fn search_query_lower(&self) -> Option<String> {
|
||||
self.search
|
||||
.as_ref()
|
||||
.filter(|s| !s.query.is_empty())
|
||||
.map(|s| s.query.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// The in-progress query prompt (`dir`, text) while the user is typing.
|
||||
pub fn input_prompt(&self) -> Option<(Dir, &str)> {
|
||||
self.input.as_ref().map(|i| (i.dir, i.query.as_str()))
|
||||
}
|
||||
|
||||
/// Number of logical lines matching the committed search, for the status
|
||||
/// readout, plus the 1-based rank of the current match within them.
|
||||
pub fn match_stats(&self) -> Option<(usize, usize)> {
|
||||
let query = self.search.as_ref()?.query.to_ascii_lowercase();
|
||||
if query.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let lines = self.display_lines();
|
||||
let mut total = 0;
|
||||
let mut rank = 0;
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
if l.to_ascii_lowercase().contains(&query) {
|
||||
total += 1;
|
||||
if Some(i) == self.current_match {
|
||||
rank = total;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((rank, total))
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a display-row index to the logical line that contains it.
|
||||
fn line_at_row(counts: &[usize], target_row: usize) -> usize {
|
||||
let mut acc = 0;
|
||||
for (i, &rc) in counts.iter().enumerate() {
|
||||
if target_row < acc + rc {
|
||||
return i;
|
||||
}
|
||||
acc += rc;
|
||||
}
|
||||
counts.len().saturating_sub(1)
|
||||
}
|
||||
|
||||
fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
|
||||
@@ -516,172 +214,3 @@ fn spawn_input() -> mpsc::UnboundedReceiver<KeyEvent> {
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Headless end-to-end: drive the real `on_key` and render through
|
||||
//! ratatui's `TestBackend`, so the full key → state → draw path is
|
||||
//! exercised without a TTY or a live pod.
|
||||
use super::*;
|
||||
use crate::ports::Ports;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
/// Build an `App` over a throwaway pod and a channel whose receiver we keep
|
||||
/// so `cmds.send` never fails.
|
||||
fn app() -> (App, mpsc::UnboundedReceiver<Cmd>) {
|
||||
let root = std::env::temp_dir().join(format!("omnidev-tui-{}", std::process::id()));
|
||||
let dir = root.join("pod");
|
||||
let pod = Arc::new(
|
||||
Pod::create(
|
||||
root.clone(),
|
||||
dir,
|
||||
Ports {
|
||||
server: 6767,
|
||||
vite: 5173,
|
||||
},
|
||||
"127.0.0.1".into(),
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let shared = Shared::new(&pod);
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
(App::new(pod, shared, tx), rx)
|
||||
}
|
||||
|
||||
fn press(app: &mut App, code: KeyCode) {
|
||||
app.on_key(KeyEvent::new(code, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
fn type_str(app: &mut App, s: &str) {
|
||||
for c in s.chars() {
|
||||
press(app, KeyCode::Char(c));
|
||||
}
|
||||
}
|
||||
|
||||
/// Render one frame at the given size and return the body rows (everything
|
||||
/// between the 4 header rows and the footer) as trimmed strings.
|
||||
fn body(app: &App, w: u16, h: u16) -> Vec<String> {
|
||||
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
|
||||
term.draw(|f| render::draw(f, app)).unwrap();
|
||||
let buf = term.backend().buffer().clone();
|
||||
let mut rows = Vec::new();
|
||||
// Layout: 4 header rows, body fills the middle, 1 footer row.
|
||||
for y in 4..h - 1 {
|
||||
let mut s = String::new();
|
||||
for x in 0..w {
|
||||
s.push_str(buf.cell((x, y)).unwrap().symbol());
|
||||
}
|
||||
rows.push(s.trim_end().to_string());
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn seed(app: &App, n: usize) {
|
||||
let mut s = app.shared.lock().unwrap();
|
||||
for i in 0..n {
|
||||
s.all.push(format!("line{i:03}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_tail_by_default() {
|
||||
let (app, _rx) = app();
|
||||
seed(&app, 100);
|
||||
let rows = body(&app, 40, 12); // 4 header + 7 body + 1 footer
|
||||
assert_eq!(rows.last().unwrap(), "line099");
|
||||
assert!(rows.iter().any(|r| r == "line093"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paging_and_ends_move_the_window() {
|
||||
let (mut app, _rx) = app();
|
||||
seed(&app, 100);
|
||||
// Establish viewport height via a first render (7 body rows).
|
||||
let _ = body(&app, 40, 12);
|
||||
press(&mut app, KeyCode::Char('b')); // page back one window
|
||||
assert!(!app.follow);
|
||||
let rows = body(&app, 40, 12);
|
||||
assert_eq!(rows.last().unwrap(), "line092");
|
||||
|
||||
press(&mut app, KeyCode::Char('g')); // top
|
||||
let rows = body(&app, 40, 12);
|
||||
assert_eq!(rows.first().unwrap(), "line000");
|
||||
|
||||
press(&mut app, KeyCode::Char('G')); // bottom + follow
|
||||
assert!(app.follow);
|
||||
let rows = body(&app, 40, 12);
|
||||
assert_eq!(rows.last().unwrap(), "line099");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_toggle_changes_row_shape() {
|
||||
let (mut app, _rx) = app();
|
||||
{
|
||||
let mut s = app.shared.lock().unwrap();
|
||||
s.all.push("X".repeat(30)); // wider than a 10-col body
|
||||
}
|
||||
// Default wrap ON: the 30-char line occupies multiple body rows.
|
||||
let wrapped = body(&app, 10, 8);
|
||||
let nonblank = wrapped.iter().filter(|r| !r.is_empty()).count();
|
||||
assert!(nonblank >= 3, "expected wrap across rows, got {wrapped:?}");
|
||||
|
||||
press(&mut app, KeyCode::Char('w')); // wrap OFF → clipped to one row
|
||||
let clipped = body(&app, 10, 8);
|
||||
let nonblank = clipped.iter().filter(|r| !r.is_empty()).count();
|
||||
assert_eq!(nonblank, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_jumps_and_highlights() {
|
||||
let (mut app, _rx) = app();
|
||||
{
|
||||
let mut s = app.shared.lock().unwrap();
|
||||
for i in 0..100 {
|
||||
let tag = if i == 5 { " ERROR here" } else { "" };
|
||||
s.all.push(format!("line{i:03}{tag}"));
|
||||
}
|
||||
}
|
||||
let _ = body(&app, 40, 12);
|
||||
// `/error` + Enter jumps up to the match near the top of the body.
|
||||
press(&mut app, KeyCode::Char('/'));
|
||||
type_str(&mut app, "error");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(app.current_match, Some(5));
|
||||
assert_eq!(app.match_stats(), Some((1, 1)));
|
||||
|
||||
// The matched line is visible and its "ERROR" is highlighted.
|
||||
let mut term = Terminal::new(TestBackend::new(40, 12)).unwrap();
|
||||
term.draw(|f| render::draw(f, &app)).unwrap();
|
||||
let buf = term.backend().buffer().clone();
|
||||
let mut highlit = 0;
|
||||
for y in 4..11 {
|
||||
for x in 0..40 {
|
||||
let cell = buf.cell((x, y)).unwrap();
|
||||
let is_match_char = matches!(cell.symbol(), "E" | "R" | "O");
|
||||
if is_match_char && cell.bg == render::match_bg() {
|
||||
highlit += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
highlit >= 5,
|
||||
"expected the match highlighted, got {highlit}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_query_does_not_run_commands() {
|
||||
let (mut app, _rx) = app();
|
||||
seed(&app, 100);
|
||||
let _ = body(&app, 40, 12);
|
||||
press(&mut app, KeyCode::Char('/'));
|
||||
// 'q' would quit in command mode; here it's just query text.
|
||||
type_str(&mut app, "q");
|
||||
assert!(!app.should_quit);
|
||||
assert_eq!(app.input_prompt(), Some((Dir::Fwd, "q")));
|
||||
press(&mut app, KeyCode::Esc);
|
||||
assert!(app.input_prompt().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+38
-387
@@ -9,9 +9,8 @@ use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Paragraph, Tabs};
|
||||
use ratatui::Frame;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use super::{App, Dir, View};
|
||||
use super::{App, View};
|
||||
use crate::state::{ProcId, ProcStatus};
|
||||
|
||||
// Palette calibrated (Solarized accents) to stay legible on both light and
|
||||
@@ -27,28 +26,16 @@ const SERVER: Color = Color::Rgb(38, 139, 210); // blue
|
||||
const HOST: Color = Color::Rgb(42, 161, 152); // cyan
|
||||
const VITE: Color = Color::Rgb(211, 54, 130); // magenta
|
||||
const EVENT: Color = Color::Rgb(181, 137, 0); // amber (omnidev channel)
|
||||
const LABEL_WIDTH: usize = 7;
|
||||
|
||||
const OK: Color = Color::Rgb(133, 153, 0); // green (running)
|
||||
const WARN: Color = Color::Rgb(203, 75, 22); // orange (starting/restarting)
|
||||
const ERR: Color = Color::Rgb(220, 50, 47); // red (crashed)
|
||||
|
||||
// Search-match highlight: amber background with near-black text, legible on
|
||||
// either theme and distinct from the ANSI log colors underneath.
|
||||
const MATCH_BG: Color = Color::Rgb(181, 137, 0);
|
||||
const MATCH_FG: Color = Color::Rgb(20, 20, 20);
|
||||
|
||||
/// Style for the header/footer chrome bars.
|
||||
fn chrome() -> Style {
|
||||
Style::default().bg(CHROME_BG).fg(CHROME_FG)
|
||||
}
|
||||
|
||||
/// The search-match background, exposed for tests that assert highlighting.
|
||||
#[cfg(test)]
|
||||
pub fn match_bg() -> Color {
|
||||
MATCH_BG
|
||||
}
|
||||
|
||||
pub fn draw(f: &mut Frame, app: &App) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
@@ -67,7 +54,7 @@ pub fn draw(f: &mut Frame, app: &App) {
|
||||
draw_chips(f, app, chunks[2]);
|
||||
draw_tabs_row(f, app, chunks[3]);
|
||||
draw_body(f, app, chunks[4]);
|
||||
draw_footer(f, app, chunks[5]);
|
||||
draw_footer(f, chunks[5]);
|
||||
}
|
||||
|
||||
fn draw_pod(f: &mut Frame, app: &App, area: Rect) {
|
||||
@@ -119,7 +106,7 @@ fn draw_tabs_row(f: &mut Frame, app: &App, area: Rect) {
|
||||
// Split the row: tabs on the left, scroll/follow status right-aligned.
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Min(0), Constraint::Length(36)])
|
||||
.constraints([Constraint::Min(0), Constraint::Length(24)])
|
||||
.split(area);
|
||||
|
||||
let entries = [
|
||||
@@ -147,18 +134,11 @@ fn draw_tabs_row(f: &mut Frame, app: &App, area: Rect) {
|
||||
f.render_widget(tabs, cols[0]);
|
||||
|
||||
let total = app.line_count();
|
||||
let mut status = format!("{total} ln");
|
||||
if !app.wrap {
|
||||
status.push_str(" · nowrap");
|
||||
}
|
||||
if let Some((rank, count)) = app.match_stats() {
|
||||
status.push_str(&format!(" · {rank}/{count}"));
|
||||
}
|
||||
if app.follow {
|
||||
status.push_str(" · follow ");
|
||||
let status = if app.follow {
|
||||
format!("{total} ln · follow ")
|
||||
} else {
|
||||
status.push_str(&format!(" · ↑{} ", app.scroll_back));
|
||||
}
|
||||
format!("{total} ln · ↑{} ", app.scroll_back)
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(status, Style::default().fg(MUTED))))
|
||||
.alignment(Alignment::Right)
|
||||
@@ -169,12 +149,6 @@ fn draw_tabs_row(f: &mut Frame, app: &App, area: Rect) {
|
||||
|
||||
fn draw_body(f: &mut Frame, app: &App, area: Rect) {
|
||||
let all_view = app.view == View::All;
|
||||
let width = area.width as usize;
|
||||
let height = area.height as usize;
|
||||
// Publish the body geometry so key handling can page and search can wrap.
|
||||
app.viewport_h.set(height);
|
||||
app.viewport_w.set(width);
|
||||
|
||||
let shared = app.shared.lock().unwrap();
|
||||
let lines: Vec<String> = match app.view {
|
||||
View::Server => shared.buf(ProcId::Server).iter().cloned().collect(),
|
||||
@@ -184,230 +158,54 @@ fn draw_body(f: &mut Frame, app: &App, area: Rect) {
|
||||
};
|
||||
drop(shared);
|
||||
|
||||
let query = app.search_query_lower();
|
||||
let visible = visible_rows(
|
||||
&lines,
|
||||
all_view,
|
||||
width,
|
||||
height,
|
||||
app.wrap,
|
||||
app.scroll_back,
|
||||
query.as_deref(),
|
||||
let height = area.height as usize;
|
||||
let total = lines.len();
|
||||
let max_back = total.saturating_sub(height);
|
||||
let back = app.scroll_back.min(max_back);
|
||||
let end = total.saturating_sub(back);
|
||||
let start = end.saturating_sub(height);
|
||||
|
||||
let rendered: Vec<Line> = lines[start..end]
|
||||
.iter()
|
||||
.map(|l| render_line(l, all_view))
|
||||
.collect();
|
||||
|
||||
f.render_widget(Paragraph::new(rendered), area);
|
||||
}
|
||||
|
||||
fn draw_footer(f: &mut Frame, area: Rect) {
|
||||
let hint = " 1/2/3/0 view · Tab cycle · ↑↓/PgUp/PgDn scroll · f follow · r restart · R backend · c clear · q quit ";
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
hint,
|
||||
Style::default().fg(CHROME_FG),
|
||||
)))
|
||||
.style(chrome()),
|
||||
area,
|
||||
);
|
||||
f.render_widget(Paragraph::new(visible), area);
|
||||
}
|
||||
|
||||
/// The window of display rows to show: the `height` rows sitting `scroll_back`
|
||||
/// rows above the tail. Rows are built from the bottom up, wrapping only enough
|
||||
/// logical lines to cover `scroll_back + height` so a full buffer isn't
|
||||
/// re-parsed every frame. Equivalent to wrapping every line and slicing the
|
||||
/// flat list, but without the wasted work.
|
||||
fn visible_rows(
|
||||
lines: &[String],
|
||||
all_view: bool,
|
||||
width: usize,
|
||||
height: usize,
|
||||
wrap: bool,
|
||||
scroll_back: usize,
|
||||
query: Option<&str>,
|
||||
) -> Vec<Line<'static>> {
|
||||
// `acc` holds rows bottom-to-top; each logical line yields one row (wrap
|
||||
// off) or several (wrap on), so `scroll_back` counts rendered rows.
|
||||
let needed = scroll_back.saturating_add(height);
|
||||
let mut acc: Vec<Line> = Vec::with_capacity(needed + 8);
|
||||
let mut exhausted = true;
|
||||
for raw in lines.iter().rev() {
|
||||
let spans = render_line(raw, all_view);
|
||||
let ranges = query.map(|q| match_ranges(raw, all_view, q));
|
||||
let mut line_rows: Vec<Line> = Vec::new();
|
||||
wrap_spans(spans, width, wrap, ranges.as_deref(), &mut line_rows);
|
||||
acc.extend(line_rows.into_iter().rev());
|
||||
if acc.len() >= needed {
|
||||
exhausted = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we ran out of lines the buffer is shorter than the scroll offset, so
|
||||
// clamp to the top; otherwise `scroll_back` is within range as-is.
|
||||
let back = if exhausted {
|
||||
scroll_back.min(acc.len().saturating_sub(height))
|
||||
} else {
|
||||
scroll_back
|
||||
};
|
||||
let end = (back + height).min(acc.len());
|
||||
let mut visible: Vec<Line> = acc.drain(back..end).collect();
|
||||
visible.reverse();
|
||||
visible
|
||||
}
|
||||
|
||||
fn draw_footer(f: &mut Frame, app: &App, area: Rect) {
|
||||
// While typing a query the footer becomes the search prompt with a cursor
|
||||
// block; otherwise it lists the key hints.
|
||||
let line = if let Some((dir, query)) = app.input_prompt() {
|
||||
let sigil = match dir {
|
||||
Dir::Fwd => '/',
|
||||
Dir::Back => '?',
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {sigil}{query}"),
|
||||
Style::default().fg(CHROME_FG).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled("█", Style::default().fg(CHROME_FG)),
|
||||
])
|
||||
} else {
|
||||
let hint = " f/b page · d/u half · j/k line · g/G ends · F follow · w wrap · / ? search · n/N next · 1230/Tab view · r/R restart · c clear · q quit ";
|
||||
Line::from(Span::styled(hint, Style::default().fg(CHROME_FG)))
|
||||
};
|
||||
f.render_widget(Paragraph::new(line).style(chrome()), area);
|
||||
}
|
||||
|
||||
/// Turn one stored log line into styled spans. In the combined view the leading
|
||||
/// `[service]` tag is colored per service and the rest keeps its ANSI colors;
|
||||
/// per-service panes just pass their ANSI through.
|
||||
fn render_line(raw: &str, all_view: bool) -> Vec<Span<'static>> {
|
||||
/// Turn one stored log line into a styled `Line`. In the combined view the
|
||||
/// leading `[service]` tag is colored per service and the rest keeps its ANSI
|
||||
/// colors; per-service panes just pass their ANSI through.
|
||||
fn render_line(raw: &str, all_view: bool) -> Line<'static> {
|
||||
if all_view {
|
||||
if let Some(rest) = raw.strip_prefix('[') {
|
||||
if let Some(end) = rest.find(']') {
|
||||
let label = &rest[..end];
|
||||
let body = &rest[end + 1..];
|
||||
let mut spans = vec![Span::styled(
|
||||
format!("[{label:<LABEL_WIDTH$}]"),
|
||||
format!("[{label}]"),
|
||||
Style::default()
|
||||
.fg(label_color(label))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)];
|
||||
spans.extend(ansi_spans(body));
|
||||
return spans;
|
||||
return Line::from(spans);
|
||||
}
|
||||
}
|
||||
}
|
||||
ansi_spans(raw)
|
||||
}
|
||||
|
||||
/// The exact text `render_line` will display (ANSI stripped, `[label]` prefix
|
||||
/// included), so search offsets and wrap-row counts line up with what's drawn.
|
||||
pub fn display_text(raw: &str, all_view: bool) -> String {
|
||||
render_line(raw, all_view)
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Column width of a char for layout. Control and zero-width chars (including
|
||||
/// tabs) count as 0 — good enough for log lines.
|
||||
fn char_cols(c: char) -> usize {
|
||||
UnicodeWidthChar::width(c).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// How many display rows `text` occupies at `width` columns. Must stay in step
|
||||
/// with `wrap_spans`' row splitting so scroll math and search jumps agree.
|
||||
pub fn row_count(text: &str, width: usize, wrap: bool) -> usize {
|
||||
if !wrap || width == 0 {
|
||||
return 1;
|
||||
}
|
||||
let mut rows = 1;
|
||||
let mut col = 0;
|
||||
for c in text.chars() {
|
||||
let w = char_cols(c);
|
||||
if col + w > width && col > 0 {
|
||||
rows += 1;
|
||||
col = 0;
|
||||
}
|
||||
col += w;
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// Char-offset ranges of every case-insensitive occurrence of `query` (already
|
||||
/// ASCII-lowercased) in the line's displayed text. Offsets are in chars so they
|
||||
/// align with `wrap_spans`' per-char highlight test.
|
||||
fn match_ranges(raw: &str, all_view: bool, query: &str) -> Vec<(usize, usize)> {
|
||||
let mut ranges = Vec::new();
|
||||
if query.is_empty() {
|
||||
return ranges;
|
||||
}
|
||||
let hay: Vec<char> = display_text(raw, all_view)
|
||||
.chars()
|
||||
.map(|c| c.to_ascii_lowercase())
|
||||
.collect();
|
||||
let q: Vec<char> = query.chars().collect();
|
||||
if hay.len() < q.len() {
|
||||
return ranges;
|
||||
}
|
||||
let mut i = 0;
|
||||
while i + q.len() <= hay.len() {
|
||||
if hay[i..i + q.len()] == q[..] {
|
||||
ranges.push((i, i + q.len()));
|
||||
i += q.len();
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
ranges
|
||||
}
|
||||
|
||||
/// Split one logical line's spans into display rows, pushing each row onto
|
||||
/// `out`. When `wrap` is off (or width 0) the line stays a single row — clipped
|
||||
/// at the edge by the renderer, as before. Contiguous same-style chars coalesce
|
||||
/// into one span. Chars whose char-offset falls in a `matches` range get the
|
||||
/// search-highlight style overlaid, so a match spanning a wrap boundary lights
|
||||
/// up on both rows.
|
||||
fn wrap_spans(
|
||||
spans: Vec<Span<'static>>,
|
||||
width: usize,
|
||||
wrap: bool,
|
||||
matches: Option<&[(usize, usize)]>,
|
||||
out: &mut Vec<Line<'static>>,
|
||||
) {
|
||||
let matches = matches.unwrap_or(&[]);
|
||||
// Nothing to reflow or highlight: emit the spans as one row untouched.
|
||||
if (!wrap || width == 0) && matches.is_empty() {
|
||||
out.push(Line::from(spans));
|
||||
return;
|
||||
}
|
||||
|
||||
let in_match = |off: usize| matches.iter().any(|&(s, e)| off >= s && off < e);
|
||||
|
||||
let mut row: Vec<Span<'static>> = Vec::new();
|
||||
let mut run = String::new();
|
||||
let mut run_style: Option<Style> = None;
|
||||
let mut col = 0usize;
|
||||
let mut offset = 0usize;
|
||||
|
||||
for span in &spans {
|
||||
let base = span.style;
|
||||
for c in span.content.chars() {
|
||||
let w = char_cols(c);
|
||||
if wrap && width > 0 && col + w > width && col > 0 {
|
||||
flush_run(&mut run, run_style.unwrap_or_default(), &mut row);
|
||||
out.push(Line::from(std::mem::take(&mut row)));
|
||||
col = 0;
|
||||
}
|
||||
let style = if in_match(offset) {
|
||||
base.bg(MATCH_BG).fg(MATCH_FG).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
base
|
||||
};
|
||||
if run_style != Some(style) {
|
||||
flush_run(&mut run, run_style.unwrap_or_default(), &mut row);
|
||||
run_style = Some(style);
|
||||
}
|
||||
run.push(c);
|
||||
col += w;
|
||||
offset += 1;
|
||||
}
|
||||
}
|
||||
flush_run(&mut run, run_style.unwrap_or_default(), &mut row);
|
||||
out.push(Line::from(row));
|
||||
}
|
||||
|
||||
/// Emit the buffered same-style run as a span, clearing the buffer.
|
||||
fn flush_run(run: &mut String, style: Style, row: &mut Vec<Span<'static>>) {
|
||||
if !run.is_empty() {
|
||||
row.push(Span::styled(std::mem::take(run), style));
|
||||
}
|
||||
Line::from(ansi_spans(raw))
|
||||
}
|
||||
|
||||
/// Parse a single line of possibly-ANSI text into owned spans, falling back to
|
||||
@@ -453,150 +251,3 @@ fn status_color(st: &ProcStatus) -> Color {
|
||||
ProcStatus::Idle => MUTED,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rows(text: &str, width: usize, wrap: bool) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
wrap_spans(
|
||||
vec![Span::raw(text.to_string())],
|
||||
width,
|
||||
wrap,
|
||||
None,
|
||||
&mut out,
|
||||
);
|
||||
out.iter()
|
||||
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_off_is_one_row() {
|
||||
assert_eq!(rows("hello world", 4, false), vec!["hello world"]);
|
||||
assert_eq!(row_count("hello world", 4, false), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_splits_at_width_and_row_count_agrees() {
|
||||
let text = "abcdefgh";
|
||||
assert_eq!(rows(text, 3, true), vec!["abc", "def", "gh"]);
|
||||
assert_eq!(row_count(text, 3, true), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_char_that_does_not_fit_wraps_first() {
|
||||
// "a" then a 2-wide char into width 2: the wide char can't share the
|
||||
// row with "a", so it starts the next one.
|
||||
let rows = rows("a世", 2, true);
|
||||
assert_eq!(rows, vec!["a", "世"]);
|
||||
assert_eq!(row_count("a世", 2, true), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_width_join_does_not_add_a_row() {
|
||||
// A trailing combining mark rides the last column, not a new row.
|
||||
assert_eq!(row_count("abc\u{0301}", 3, true), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_zero_never_panics() {
|
||||
assert_eq!(rows("abc", 0, true), vec!["abc"]);
|
||||
assert_eq!(row_count("abc", 0, true), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_ranges_are_case_insensitive_char_offsets() {
|
||||
assert_eq!(
|
||||
match_ranges("Error: ERROR", false, "error"),
|
||||
vec![(0, 5), (7, 12)]
|
||||
);
|
||||
assert_eq!(match_ranges("nope", false, "error"), vec![]);
|
||||
}
|
||||
|
||||
/// Reference: wrap every line into one flat list, then slice the window —
|
||||
/// the obvious-but-wasteful version `visible_rows` optimizes.
|
||||
fn naive_visible(
|
||||
lines: &[String],
|
||||
width: usize,
|
||||
height: usize,
|
||||
wrap: bool,
|
||||
scroll_back: usize,
|
||||
) -> Vec<String> {
|
||||
let mut all: Vec<Line> = Vec::new();
|
||||
for raw in lines {
|
||||
wrap_spans(vec![Span::raw(raw.clone())], width, wrap, None, &mut all);
|
||||
}
|
||||
let total = all.len();
|
||||
let back = scroll_back.min(total.saturating_sub(height));
|
||||
let end = total.saturating_sub(back);
|
||||
let start = end.saturating_sub(height);
|
||||
all[start..end].iter().map(row_text).collect()
|
||||
}
|
||||
|
||||
fn row_text(l: &Line) -> String {
|
||||
l.spans.iter().map(|s| s.content.as_ref()).collect()
|
||||
}
|
||||
|
||||
fn lazy_visible(
|
||||
lines: &[String],
|
||||
width: usize,
|
||||
height: usize,
|
||||
wrap: bool,
|
||||
scroll_back: usize,
|
||||
) -> Vec<String> {
|
||||
visible_rows(lines, false, width, height, wrap, scroll_back, None)
|
||||
.iter()
|
||||
.map(row_text)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lazy_slice_matches_naive_across_offsets() {
|
||||
let lines: Vec<String> = (0..30).map(|i| format!("line{i:02}=abcdefghij")).collect();
|
||||
for &wrap in &[false, true] {
|
||||
for width in [6usize, 8, 40] {
|
||||
for height in [1usize, 5, 12] {
|
||||
for back in [0usize, 3, 10, 25, 999] {
|
||||
assert_eq!(
|
||||
lazy_visible(&lines, width, height, wrap, back),
|
||||
naive_visible(&lines, width, height, wrap, back),
|
||||
"wrap={wrap} width={width} height={height} back={back}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_short_buffers_do_not_panic() {
|
||||
assert!(lazy_visible(&[], 10, 5, true, 0).is_empty());
|
||||
let one = vec!["hi".to_string()];
|
||||
assert_eq!(lazy_visible(&one, 10, 5, true, 0), vec!["hi"]);
|
||||
assert_eq!(lazy_visible(&one, 10, 5, true, 99), vec!["hi"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_survives_a_wrap_boundary() {
|
||||
// "error" at chars 2..7 straddles the width-4 wrap between rows.
|
||||
let ranges = match_ranges("--error--", false, "error");
|
||||
let mut out = Vec::new();
|
||||
wrap_spans(
|
||||
vec![Span::raw("--error--".to_string())],
|
||||
4,
|
||||
true,
|
||||
Some(&ranges),
|
||||
&mut out,
|
||||
);
|
||||
// Every row that overlaps the match must carry a highlighted span.
|
||||
let highlighted: usize = out
|
||||
.iter()
|
||||
.flat_map(|l| &l.spans)
|
||||
.filter(|s| s.style.bg == Some(MATCH_BG))
|
||||
.map(|s| s.content.chars().count())
|
||||
.sum();
|
||||
assert_eq!(highlighted, 5); // all five chars of "error"
|
||||
}
|
||||
}
|
||||
|
||||
+7
-117
@@ -3,38 +3,24 @@
|
||||
//! handles those.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use ignore::gitignore::{Gitignore, GitignoreBuilder};
|
||||
use notify::RecursiveMode;
|
||||
use notify_debouncer_full::new_debouncer;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::state::Shared;
|
||||
use crate::supervisor::Cmd;
|
||||
|
||||
/// Start watching `omnigent_dir` for `*.py` changes. Coalesced bursts become a
|
||||
/// single `Cmd::Reload(n)` on `cmd_tx`. The returned debouncer must be kept
|
||||
/// alive for the watch to persist.
|
||||
///
|
||||
/// Gitignored files (e.g. the build-time `omnigent/_build_info.py`) are skipped
|
||||
/// so churn from generated files doesn't trigger reloads. With `debug` on, every
|
||||
/// observed change is logged with whether it triggered a reload or why it was
|
||||
/// skipped.
|
||||
pub fn spawn(
|
||||
repo_root: &Path,
|
||||
omnigent_dir: &Path,
|
||||
shared: Arc<Mutex<Shared>>,
|
||||
debug: bool,
|
||||
cmd_tx: mpsc::UnboundedSender<Cmd>,
|
||||
) -> Result<impl Send + 'static> {
|
||||
let ignore = build_ignore(repo_root);
|
||||
let repo_root = repo_root.to_path_buf();
|
||||
|
||||
// The debouncer coalesces rapid saves; we still filter to *.py, skip caches
|
||||
// and gitignored files so editor churn and generated writes don't reload.
|
||||
// The debouncer coalesces rapid saves; we still filter to *.py and skip
|
||||
// caches so editor churn and __pycache__ writes don't trigger reloads.
|
||||
let mut debouncer = new_debouncer(
|
||||
Duration::from_millis(500),
|
||||
None,
|
||||
@@ -43,18 +29,8 @@ pub fn spawn(
|
||||
let mut changed = 0usize;
|
||||
for event in &events {
|
||||
for path in &event.paths {
|
||||
match classify(path, &ignore) {
|
||||
Ok(()) => {
|
||||
changed += 1;
|
||||
if debug {
|
||||
log_watch(&shared, &repo_root, path, "reload trigger");
|
||||
}
|
||||
}
|
||||
Err(reason) => {
|
||||
if debug {
|
||||
log_watch(&shared, &repo_root, path, &format!("skip ({reason})"));
|
||||
}
|
||||
}
|
||||
if is_relevant(path) {
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,95 +48,9 @@ pub fn spawn(
|
||||
Ok(debouncer)
|
||||
}
|
||||
|
||||
/// Build a gitignore matcher from the repo's root `.gitignore` and
|
||||
/// `.git/info/exclude`. Both are best-effort — a missing or malformed file just
|
||||
/// contributes no rules. Nested `.gitignore` files under `omnigent/` are not
|
||||
/// consulted (the repo has none today); add them here if that changes.
|
||||
fn build_ignore(repo_root: &Path) -> Gitignore {
|
||||
let mut b = GitignoreBuilder::new(repo_root);
|
||||
b.add(repo_root.join(".gitignore"));
|
||||
b.add(repo_root.join(".git").join("info").join("exclude"));
|
||||
b.build().unwrap_or_else(|_| Gitignore::empty())
|
||||
}
|
||||
|
||||
/// Decide whether a changed path should trigger a reload, or why not. The `Err`
|
||||
/// carries a short reason for the `--debug` log.
|
||||
fn classify(path: &Path, ignore: &Gitignore) -> Result<(), &'static str> {
|
||||
fn is_relevant(path: &Path) -> bool {
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("py") {
|
||||
return Err("non-.py");
|
||||
}
|
||||
if path.components().any(|c| c.as_os_str() == "__pycache__") {
|
||||
return Err("__pycache__");
|
||||
}
|
||||
// `_or_any_parents` so files inside a gitignored directory (build/, dist/,
|
||||
// *.egg-info/, …) are skipped too, matching git's own behavior — plain
|
||||
// `matched` only catches paths named by a rule directly.
|
||||
if ignore.matched_path_or_any_parents(path, false).is_ignore() {
|
||||
return Err("gitignored");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emit a `--debug` watch line into the combined pane, path shown relative to
|
||||
/// the repo root when possible.
|
||||
fn log_watch(shared: &Arc<Mutex<Shared>>, repo_root: &Path, path: &Path, what: &str) {
|
||||
let rel = path.strip_prefix(repo_root).unwrap_or(path);
|
||||
shared
|
||||
.lock()
|
||||
.unwrap()
|
||||
.event(format!("watch: {what} {}", rel.display()));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ignore_with(line: &str) -> Gitignore {
|
||||
let mut b = GitignoreBuilder::new("/repo");
|
||||
b.add_line(None, line).unwrap();
|
||||
b.build().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_python_file_triggers_reload() {
|
||||
let ig = ignore_with("omnigent/_build_info.py");
|
||||
assert_eq!(classify(Path::new("/repo/omnigent/cli.py"), &ig), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitignored_python_file_is_skipped() {
|
||||
let ig = ignore_with("omnigent/_build_info.py");
|
||||
assert_eq!(
|
||||
classify(Path::new("/repo/omnigent/_build_info.py"), &ig),
|
||||
Err("gitignored")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_inside_gitignored_dir_is_skipped() {
|
||||
// A directory rule must ignore everything beneath it, like git does.
|
||||
let ig = ignore_with("build/");
|
||||
assert_eq!(
|
||||
classify(Path::new("/repo/omnigent/build/foo.py"), &ig),
|
||||
Err("gitignored")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_python_file_is_skipped() {
|
||||
let ig = ignore_with("omnigent/_build_info.py");
|
||||
assert_eq!(
|
||||
classify(Path::new("/repo/omnigent/notes.txt"), &ig),
|
||||
Err("non-.py")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pycache_file_is_skipped() {
|
||||
let ig = ignore_with("omnigent/_build_info.py");
|
||||
assert_eq!(
|
||||
classify(Path::new("/repo/omnigent/__pycache__/cli.py"), &ig),
|
||||
Err("__pycache__")
|
||||
);
|
||||
return false;
|
||||
}
|
||||
!path.components().any(|c| c.as_os_str() == "__pycache__")
|
||||
}
|
||||
|
||||
@@ -1,591 +0,0 @@
|
||||
# Omnigent Uninstaller Design
|
||||
|
||||
Status: Implemented in PR #2550
|
||||
Owner: Pat Sukprasert (@PattaraS)
|
||||
Related discussion: brainstormed and debated via Debby (claude + gpt partners)
|
||||
|
||||
Implementation note: PR #2550 ships the OSS CLI/script implementation as one
|
||||
combined PR rather than the staged PR breakdown below. Checkboxes marked here
|
||||
reflect the current implementation and focused test coverage in that PR.
|
||||
|
||||
This document specifies how Omnigent should be uninstalled. It is written to be
|
||||
handed to an implementer without further design decisions. Track delivery with
|
||||
the checklists in each section.
|
||||
|
||||
## 1. Overview and scope
|
||||
|
||||
Ship four coupled pieces around one shared removal codepath:
|
||||
|
||||
1. `scripts/uninstall_oss.sh` - pure POSIX `sh`, the actual removal logic. Works
|
||||
even when the wheel is wedged or PATH is broken; usable via curl-pipe.
|
||||
2. `omnigent uninstall` - the discoverable CLI entry. It performs graceful
|
||||
process shutdown and state/JSON handling in Python, then execs
|
||||
`uninstall_oss.sh` for the final self-removal steps. One implementation, two
|
||||
entry points.
|
||||
3. Install-side ledger writer - records what the installer did to
|
||||
`~/.omnigent/install_ledger.json`.
|
||||
4. Back-fill routine - reconstructs a ledger as observed evidence (never
|
||||
invented memory) for the pre-ledger install base.
|
||||
|
||||
Out of scope: any cross-domain "reaper" spanning the wheel, the signed `.app`,
|
||||
and mobile sandboxes. App-store surfaces (iOS/Android/Electron) use OS-native
|
||||
uninstall and only point the user back at `omnigent uninstall --purge` for
|
||||
`~/.omnigent`. Shared runtimes (uv/Node/tmux/bwrap) are report-only in this
|
||||
version - never removed, even with `--yes`.
|
||||
|
||||
Design principles that recur below:
|
||||
|
||||
- Remove only what we own; report everything else.
|
||||
- Preserve user data by default; destruction is a separate, explicit intent.
|
||||
- Risk is a property of the artifact, not of how we learned about it.
|
||||
- Stop before you delete.
|
||||
- Idempotent by state-check, not error-swallowing.
|
||||
|
||||
## 2. install_ledger.json schema
|
||||
|
||||
- Path: `~/.omnigent/install_ledger.json`
|
||||
- Mode: `0600` (local paths; treat as sensitive)
|
||||
- Write: atomic - write `install_ledger.json.tmp` in the same dir, `fsync`,
|
||||
`rename()` over target.
|
||||
- `schema_version`: `1` for first ship. Bump on any breaking change.
|
||||
|
||||
### Top level
|
||||
|
||||
| Field | Type | Allowed / notes |
|
||||
|---|---|---|
|
||||
| `schema_version` | int | `1`. |
|
||||
| `ledger_source` | enum | `installer` \| `backfill`. A backfill ledger never overwrites an installer one. |
|
||||
| `generator` | object | `{name, version, strategy, os, wrote_at}`; `strategy` = `install` \| `fast-backfill` \| `deep-backfill`; `os` = `macos` \| `linux`. |
|
||||
| `installation_id` | string \| null | Copied from `~/.omnigent/installation_id`; the anchor proving an install exists. |
|
||||
| `created_at` / `updated_at` / `last_validated_at` | string | RFC3339 UTC. |
|
||||
| `entries` | object | The reversible-action records (below). |
|
||||
|
||||
### Per-entry provenance (every entry carries both)
|
||||
|
||||
- `source`: `recorded` (installer saw itself act) \| `observed` (backfill saw
|
||||
the artifact directly) \| `inferred` (backfill deduced it).
|
||||
- `confidence`: `certain` \| `high` \| `medium` \| `low` \| `none`.
|
||||
|
||||
### entries sub-objects
|
||||
|
||||
`profiles` (array) - shell profiles that received the delimited PATH block:
|
||||
`path`, `marker_begin` (`# >>> Omnigent installer >>>`), `marker_end`
|
||||
(`# <<< Omnigent installer <<<`), `line_range` [int,int] (1-indexed inclusive,
|
||||
advisory - removal re-locates by marker), `block_sha256` (of block text incl.
|
||||
markers, for tamper detection), `content_matches_current` (bool), `source`,
|
||||
`confidence`.
|
||||
|
||||
`injected_external_config` (array) - entries Omnigent wrote into third-party
|
||||
files: `path`, `marker` (logical key, e.g. `mcp_servers.omnigent`), `format`
|
||||
(`json` \| `toml` \| `delimited_block`), `allowlist` (array of exact key paths /
|
||||
block markers we may remove - removal touches ONLY these), `block_sha256`
|
||||
(\| null), `source`, `confidence`.
|
||||
|
||||
`deps` (object keyed by `uv`/`node`/`npm`/`tmux`/`bwrap`): `present` (bool),
|
||||
`path` (\| null), `version` (\| null), `installed_by` (`omnigent` - only ever set
|
||||
by a real installer that did the install; \| `preexisting` \| `unknown` -
|
||||
backfill may only write `unknown`), `confidence` (`none` whenever
|
||||
`installed_by=="unknown"`), optional `notes` (weak human hint, never actioned).
|
||||
|
||||
`wheel` (object): `installed` (bool), `uv_tool_dir` (\| null), `bin_dir`
|
||||
(\| null, e.g. `~/.local/bin`), `console_scripts` (array, e.g.
|
||||
`["omnigent","omni"]`), `source`, `confidence`.
|
||||
|
||||
`launch_agents` (array): `kind` (`launchd` \| `systemd_user`), `path`, `label`,
|
||||
`source`, `confidence`.
|
||||
|
||||
`state_paths` (object, informational, only removed under `--purge`):
|
||||
`omnigent_home` (`~/.omnigent`), `workspace` (`~/omnigent`), `desktop_data`
|
||||
(array of observed Electron dirs).
|
||||
|
||||
### Annotated example
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"ledger_source": "installer",
|
||||
"installation_id": "b1f3c9a2-7e40-4c11-9d2a-3f6e8c0a1b22",
|
||||
"created_at": "2026-07-14T18:03:22Z",
|
||||
"updated_at": "2026-07-14T18:03:22Z",
|
||||
"last_validated_at": "2026-07-14T18:03:22Z",
|
||||
"generator": { "name": "omnigent", "version": "1.42.0", "strategy": "install", "os": "macos", "wrote_at": "2026-07-14T18:03:22Z" },
|
||||
"entries": {
|
||||
"profiles": [
|
||||
{ "path": "~/.zshrc", "marker_begin": "# >>> Omnigent installer >>>", "marker_end": "# <<< Omnigent installer <<<",
|
||||
"line_range": [212, 215], "block_sha256": "9f2c...e1", "content_matches_current": true,
|
||||
"source": "recorded", "confidence": "certain" }
|
||||
],
|
||||
"injected_external_config": [
|
||||
{ "path": "~/.config/harness/hermes.json", "marker": "mcp_servers.omnigent", "format": "json",
|
||||
"allowlist": ["mcp_servers.omnigent"], "block_sha256": null, "source": "recorded", "confidence": "certain" }
|
||||
],
|
||||
"deps": {
|
||||
"uv": { "present": true, "path": "~/.local/bin/uv", "version": "0.5.11", "installed_by": "omnigent", "confidence": "high" },
|
||||
"node": { "present": true, "path": "/usr/bin/node", "version": "22.3.0", "installed_by": "preexisting", "confidence": "high" }
|
||||
},
|
||||
"wheel": { "installed": true, "uv_tool_dir": "~/.local/share/uv/tools/omnigent", "bin_dir": "~/.local/bin",
|
||||
"console_scripts": ["omnigent","omni"], "source": "recorded", "confidence": "certain" },
|
||||
"launch_agents": [
|
||||
{ "kind": "launchd", "path": "~/Library/LaunchAgents/dev.omnigent.daemon.plist", "label": "dev.omnigent.daemon",
|
||||
"source": "recorded", "confidence": "certain" }
|
||||
],
|
||||
"state_paths": { "omnigent_home": "~/.omnigent", "workspace": "~/omnigent", "desktop_data": [] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Checklist:
|
||||
|
||||
- [x] Schema documented and versioned (`schema_version = 1`)
|
||||
- [x] Atomic writer (tmp + fsync + rename) with `0600` mode
|
||||
- [x] Serializer / dataclass with round-trip unit tests
|
||||
- [x] `omnigent _internal write-ledger --from-env` hidden subcommand
|
||||
|
||||
## 3. Install-side ledger writer
|
||||
|
||||
Hook point: in `scripts/install_oss.sh`, after all side effects succeed and
|
||||
before `print_next_steps`. Since the installer is the source of truth, prefer
|
||||
having it call the hidden serializer subcommand
|
||||
`omnigent _internal write-ledger --from-env` (reuses the schema serializer, gets
|
||||
atomic-write + `0600` for free) rather than hand-building JSON in `sh`. Provide a
|
||||
`write_install_ledger` shell wrapper.
|
||||
|
||||
Records (all `source: recorded`): each profile actually edited (path, markers,
|
||||
current `line_range`, `block_sha256`); each external-config injection (path,
|
||||
marker, format, allowlist); the wheel install (`uv tool dir`, bin dir, console
|
||||
scripts); deps the installer itself installed this run get
|
||||
`installed_by: omnigent` + version, deps found already present get
|
||||
`preexisting`; any LaunchAgent/systemd unit registered; `installation_id`;
|
||||
`state_paths`. Do not shell out to package managers for versions - cheap
|
||||
`--version` only.
|
||||
|
||||
Upgrade / repair sync:
|
||||
|
||||
1. If existing ledger is `backfill`, discard and write a fresh `installer`
|
||||
ledger (a real record supersedes inference).
|
||||
2. If `installer`, merge: refresh `block_sha256`/`line_range` for re-touched
|
||||
profiles, refresh wheel/dep versions, add newly-injected external config,
|
||||
bump `generator.version` + `updated_at`.
|
||||
3. Never downgrade `installed_by` (`uv: omnigent` stays even if uv is now found
|
||||
pre-present).
|
||||
4. Atomic write.
|
||||
|
||||
Checklist:
|
||||
|
||||
- [x] `write_install_ledger` hooked into `scripts/install_oss.sh` (post
|
||||
side-effects, pre next-steps)
|
||||
- [x] Records profiles, external config, wheel, deps, launch agents, state paths
|
||||
- [x] Upgrade/repair merge logic (backfill superseded by installer; never
|
||||
downgrade `installed_by`)
|
||||
- [x] Tests: fresh install, upgrade, backfill-superseded-by-installer
|
||||
|
||||
## 4. Back-fill routine
|
||||
|
||||
Reconstruction = observe current state, record with per-field confidence, never
|
||||
invent provenance.
|
||||
|
||||
Anchor guard (refuse to fabricate): before writing anything, require at least
|
||||
one genuine install signal: `~/.omnigent/installation_id` exists, OR the wheel
|
||||
is installed (`uv tool list` shows `omnigent`), OR a known profile contains the
|
||||
exact marker pair. If none, write nothing and report "no Omnigent install
|
||||
detected."
|
||||
|
||||
Fast vs deep:
|
||||
|
||||
- Fast (startup, target <100ms, no package-manager subprocesses): stat the
|
||||
ledger; if valid, return. Else cheap checks only - stat `installation_id`,
|
||||
read + in-process scan of candidate profiles for markers (no shelling out to
|
||||
`grep`), stat known `~/.omnigent` subdirs, existence checks for Electron
|
||||
dirs. Mark wheel/deps `confidence: low` or omit; `generator.strategy =
|
||||
fast-backfill`. Never spawn `uv`/`command -v` on the hot path.
|
||||
- Deep (uninstall / doctor, no budget): fast steps plus `uv tool list`/
|
||||
`uv tool dir`, `command -v omnigent omni uv node tmux bwrap`, version
|
||||
resolution, allowlisted external-config marker scans, LaunchAgent/systemd
|
||||
enumeration. `generator.strategy = deep-backfill`.
|
||||
|
||||
Per-field confidence assignment:
|
||||
|
||||
| Signal | source | confidence |
|
||||
|---|---|---|
|
||||
| PATH block present (marker match) | observed | certain |
|
||||
| PATH block present, content != current | observed | certain (flag `content_matches_current:false`) |
|
||||
| Wheel / bin dir / console scripts | observed | high |
|
||||
| `~/.omnigent`, `installation_id` | observed | high |
|
||||
| LaunchAgent by known label | observed | high |
|
||||
| Injected external config (marker block) | observed | certain |
|
||||
| Injected external config (header fingerprint, no marker) | inferred | medium |
|
||||
| Any dep `installed_by` | inferred | unknown / none |
|
||||
|
||||
Dependency `installed_by` is unrecoverable by design: backfill may write
|
||||
`present`/`path`/`version` but MUST write `installed_by: unknown`,
|
||||
`confidence: none`. A `notes` hint is allowed for `--dry-run` readers but never
|
||||
changes behavior.
|
||||
|
||||
Never-overwrite-real + double-ledger:
|
||||
|
||||
- If existing ledger is `installer`, backfill does nothing, ever.
|
||||
- Backfill writes to `~/.omnigent/install_ledger.backfill.json`, not directly
|
||||
over `install_ledger.json`.
|
||||
- Uninstaller ledger resolution: use `install_ledger.json` if `installer`; else
|
||||
use `install_ledger.backfill.json` if present; else run deep backfill on the
|
||||
fly.
|
||||
- Re-run replaces the backfill file only if content differs; else bump
|
||||
`last_validated_at`.
|
||||
|
||||
Read-only-except-the-ledger: backfill never edits profiles, removes deps, or
|
||||
stops processes. It only reads and writes the (backfill) ledger.
|
||||
|
||||
Triggers: eager fast-backfill on first CLI run when missing; lazy deep-backfill
|
||||
at uninstall when missing; explicit
|
||||
`omnigent doctor --migrate-ledger [--deep]` which prints a JSON diff and writes
|
||||
only with `--apply`.
|
||||
|
||||
Checklist:
|
||||
|
||||
- [x] Fast reconstruction (<100ms, no package-manager subprocesses, in-process
|
||||
marker scan) on startup when missing
|
||||
- [x] Deep reconstruction at uninstall / doctor
|
||||
- [x] Anchor guard (refuse to fabricate without an install signal)
|
||||
- [x] Per-field confidence assignment per table
|
||||
- [x] Never-overwrite-real + `install_ledger.backfill.json` double-ledger handling
|
||||
- [x] `omnigent doctor --migrate-ledger [--deep] [--apply]`
|
||||
- [x] Read-only-except-the-ledger guarantee (tested)
|
||||
|
||||
## 5. omnigent uninstall CLI
|
||||
|
||||
`omnigent uninstall [targets...] [flags...]` (execs `scripts/uninstall_oss.sh`
|
||||
with the same args). Fallback: `scripts/uninstall_oss.sh [targets...]
|
||||
[flags...]`.
|
||||
|
||||
Targets (default `cli` if none given):
|
||||
|
||||
- `cli` - remove the uv tool entry + PATH/profile block(s).
|
||||
- `state` - remove user data under `~/.omnigent` and `~/omnigent` (backup by
|
||||
default).
|
||||
- `desktop-data` - remove Electron caches/support/logs (NOT the app bundle).
|
||||
- `all` - alias for `cli state desktop-data`.
|
||||
|
||||
Flags:
|
||||
|
||||
- `--purge` - implies `state`; deletes state/caches; backs up first unless
|
||||
`--no-backup`.
|
||||
- `--dry-run` - print exact planned actions (paths, sizes, line ranges); make no
|
||||
changes.
|
||||
- With no destructive flag (`--yes`, `--purge`, `--force`,
|
||||
`--modify-external-config`, `--no-backup`, `--assume-inferred`, or
|
||||
`--purge-workspace`), uninstall defaults to dry-run preview mode.
|
||||
- `--yes` - non-interactive; suppresses prompts for auto-removable artifacts
|
||||
only. Does NOT imply `--purge`.
|
||||
- `--json` - machine-readable output.
|
||||
- `--force` - allow SIGKILL after the SIGTERM grace window; proceed if daemons
|
||||
resist; override tamper-refusal.
|
||||
- `--modify-external-config` - primary gate to touch third-party config files.
|
||||
- `--no-backup` - with `state`/`--purge`, skip archive creation.
|
||||
- `--assume-inferred` - secondary gate to act on `inferred` entries.
|
||||
- `--purge-workspace` - the only way to clear `~/omnigent` (your working files)
|
||||
non-interactively. Without it, `--purge --yes` still removes `~/.omnigent`
|
||||
(credentials/history) but leaves `~/omnigent` untouched and prints a notice.
|
||||
This keeps a stray `--yes` in automation from wiping user work.
|
||||
|
||||
Gate decision table. Two orthogonal gates. Intrinsic-risk (primary): own
|
||||
reversible artifacts auto-remove under `--yes`; third-party edits and data
|
||||
destruction need their explicit flag on both real and backfilled ledgers.
|
||||
Confidence (secondary, tighten-only): an `inferred`/low-confidence entry
|
||||
escalates one notch and won't auto-act under bare `--yes` - it can only add
|
||||
friction, never grant it.
|
||||
|
||||
| Artifact | No destructive flags | `--yes` | Required gate |
|
||||
|---|---|---|---|
|
||||
| Wheel (`uv tool uninstall omnigent`) | dry-run preview | auto-remove | none |
|
||||
| Delimited PATH block (marker match) | dry-run preview | auto-remove | none; refuse if `block_sha256` mismatch (tampered) unless `--force` |
|
||||
| Injected external config, marker/observed | reported, skipped | reported, skipped | `--modify-external-config` |
|
||||
| Injected external config, inferred (no marker) | reported, skipped | reported, skipped | `--modify-external-config` AND `--assume-inferred` |
|
||||
| `~/.omnigent` state root | reported, skipped | removed only with `--purge` | `--purge` |
|
||||
| `~/omnigent` workspace | reported, skipped | kept unless `--purge-workspace` | `--purge` AND (`--purge-workspace` or interactive confirm) |
|
||||
| Desktop data | via `desktop-data`/`all` | same | none beyond target |
|
||||
| Shared deps (uv/node/tmux/bwrap) | report-only | report-only | none - never removed this version |
|
||||
|
||||
Checklist:
|
||||
|
||||
- [x] Python `omnigent uninstall` subcommand that execs the shell script
|
||||
- [x] Targets: `cli`, `state`, `desktop-data`, `all`
|
||||
- [x] Flags: `--purge`, `--purge-workspace`, `--dry-run`, `--yes`, `--json`,
|
||||
`--force`, `--modify-external-config`, `--no-backup`, `--assume-inferred`
|
||||
- [x] Two-gate decision table implemented (intrinsic-risk + confidence
|
||||
tighten-only)
|
||||
- [x] External-config stripping (marker/allowlist scoped only)
|
||||
|
||||
## 6. Order of operations
|
||||
|
||||
`omnigent uninstall` performs graceful shutdown + state/JSON in Python, then
|
||||
execs the shell script for removal. Sequence:
|
||||
|
||||
1. Resolve ledger (section 4 resolution order).
|
||||
2. Stop processes first. Read pidfiles under `~/.omnigent/run/` (+ `daemons/`,
|
||||
`runners/`, `local_server/`): SIGTERM -> wait 5s -> under `--force` SIGKILL.
|
||||
Kill only `omnigent:*` tmux sessions. Unload ledger-recorded LaunchAgents/
|
||||
systemd units. If a process won't stop, abort destructive steps (report and
|
||||
exit nonzero) unless `--force`.
|
||||
3. `--dry-run`? Print exact paths + sizes + line ranges, then exit 0.
|
||||
4. Profile cleanup. Remove ONLY the delimited marker block, all shells incl.
|
||||
fish (`config.fish` + `conf.d/`). Back up the profile file first. Refuse a
|
||||
block whose `block_sha256` doesn't match the ledger (tampered) unless
|
||||
`--force`.
|
||||
5. Strip injected external config (gated per table; marker-scoped /
|
||||
allowlist-scoped only).
|
||||
6. Optional state / desktop-data (only with `--purge` / target). For `--purge`:
|
||||
archive to a backup tarball OUTSIDE the target under `~/.omnigent-backups/`
|
||||
(or `$XDG_STATE_HOME`). Prefer `<ts>.tar.zst` when `zstd` is present; fall
|
||||
back to `<ts>.tar.gz` (gzip is POSIX-baseline) otherwise. Never silently skip
|
||||
the backup because a compressor is missing - a purge that can't write its
|
||||
backup must fail closed (exit 1) unless `--no-backup` was given. Print the
|
||||
restore command, then delete. Never back up into `~/.omnigent`. Clearing
|
||||
`~/omnigent` non-interactively requires `--purge-workspace` (see section 5);
|
||||
otherwise it prompts for a separate confirm. Note that purging
|
||||
`installation_id` makes a reinstall look like a new device (telemetry).
|
||||
7. `uv tool uninstall omnigent` - LAST (so earlier Python-driven steps still
|
||||
have the wheel available).
|
||||
|
||||
Checklist:
|
||||
|
||||
- [x] Process-shutdown protocol (pidfiles, SIGTERM->5s->`--force` SIGKILL,
|
||||
`omnigent:*` tmux, ledger LaunchAgents, abort-if-won't-stop)
|
||||
- [x] Profile block removal across all shells incl. fish; profile backed up
|
||||
first; tamper-refusal
|
||||
- [x] `--purge` archives OUTSIDE the target (`.tar.zst`, gzip fallback; fail
|
||||
closed if it can't write the backup), prints restore command, then
|
||||
deletes; `~/omnigent` gated behind `--purge-workspace` (or confirm)
|
||||
- [x] `uv tool uninstall omnigent` runs last
|
||||
|
||||
## 7. Idempotency and exit codes
|
||||
|
||||
State-check semantics: already-absent = success (exit 0); tried-and-failed =
|
||||
report, continue with remaining steps, exit nonzero, summarize at end. Never
|
||||
swallow a real failure as success; distinguish "already gone" from "tried and
|
||||
failed."
|
||||
|
||||
Exit codes:
|
||||
|
||||
- `0` - all planned actions done or already-absent
|
||||
- `1` - one or more actions failed (details in summary)
|
||||
- `2` - aborted before destructive steps (e.g. process would not stop without
|
||||
`--force`)
|
||||
- `3` - refused (tampered block / anchor guard / ambiguous, no `--force`)
|
||||
|
||||
`--json` output shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"dry_run": false,
|
||||
"ledger_source": "installer",
|
||||
"actions": [
|
||||
{ "artifact": "profile_block", "path": "~/.zshrc", "planned": "remove",
|
||||
"status": "done", "gate": null, "detail": "block removed, backup at ~/.zshrc.omnigent.bak" },
|
||||
{ "artifact": "external_config", "path": "~/.config/harness/hermes.json", "marker": "mcp_servers.omnigent",
|
||||
"planned": "remove", "status": "skipped", "gate": "--modify-external-config", "detail": "gate not provided" },
|
||||
{ "artifact": "shared_dep", "name": "uv", "planned": "report", "status": "reported",
|
||||
"gate": null, "detail": "installed_by=unknown; not removed" }
|
||||
],
|
||||
"backups": ["~/.omnigent-backups/2026-07-14T18-40-02Z.tar.zst"],
|
||||
"summary": { "done": 1, "skipped": 1, "failed": 0, "reported": 1 },
|
||||
"exit_code": 0
|
||||
}
|
||||
```
|
||||
|
||||
Checklist:
|
||||
|
||||
- [x] State-check idempotency (already-absent = 0; tried-and-failed = nonzero +
|
||||
continue + summarize)
|
||||
- [x] Exit codes 0/1/2/3 as specified
|
||||
- [x] `--json` output shape stable and tested
|
||||
|
||||
## 8. Test matrix
|
||||
|
||||
| # | Scenario | Expect |
|
||||
|---|---|---|
|
||||
| 1 | fish profiles (`config.fish` + `conf.d/omnigent.fish`) | block removed from both; other lines intact |
|
||||
| 2 | Tampered / corrupted marker block (sha mismatch) | refuse without `--force`; exit 3 |
|
||||
| 3 | No ledger, valid install signal | deep-backfill runs, uninstall proceeds |
|
||||
| 4 | No ledger, no install signal | anchor guard: nothing written; "no install detected" |
|
||||
| 5 | Backfilled ledger present | inferred entries need `--assume-inferred`; deps report-only |
|
||||
| 6 | Live daemon running | stopped (SIGTERM->5s->`--force`); won't-stop aborts destructive steps |
|
||||
| 7 | `--dry-run` | prints exact paths/sizes/ranges; zero mutations; exit 0 |
|
||||
| 8 | `--purge` with backup | archive written OUTSIDE `~/.omnigent`; restore command printed; then delete |
|
||||
| 9 | `--purge --no-backup` | delete without archive; `~/omnigent` kept unless `--purge-workspace` |
|
||||
| 10 | Shared dep present (`installed_by:unknown`) | report-only, never removed, even with `--yes` |
|
||||
| 11 | Double ledger (real + backfill both present) | keep real; backfill copy left as `.backfill.json` for inspection |
|
||||
| 12 | Re-run after full uninstall (idempotency) | all already-absent; exit 0 |
|
||||
| 13 | Injected external config, marker vs inferred | marker gated by `--modify-external-config`; inferred also needs `--assume-inferred` |
|
||||
| 14 | uv tool uninstall runs last | earlier Python steps had the wheel available |
|
||||
| 15 | `--purge` on a box without `zstd` | backup written as `.tar.gz`; not skipped |
|
||||
| 16 | `--purge --yes` without `--purge-workspace` | `~/.omnigent` removed; `~/omnigent` kept + notice |
|
||||
|
||||
Checklist:
|
||||
|
||||
- [x] Rows 1-2, 6-7, 12, 14 covered by `uninstall_oss.sh` tests
|
||||
- [x] Rows 3-5, 8-11, 13 covered by focused CLI, ledger, and
|
||||
`uninstall_oss.sh` tests
|
||||
|
||||
## 9. Delivery plan (PR breakdown)
|
||||
|
||||
- [x] PR 1 - Ledger schema + serializer. Schema, atomic-write + `0600` writer,
|
||||
`omnigent _internal write-ledger` hidden subcommand, round-trip unit
|
||||
tests. No behavior change.
|
||||
- [x] PR 2 - Install-side writer. Hook `write_install_ledger` into
|
||||
`scripts/install_oss.sh` + upgrade/repair merge logic.
|
||||
- [x] PR 3 - Back-fill routine. Fast + deep reconstruction, anchor guard,
|
||||
confidence assignment, never-overwrite-real + double-ledger,
|
||||
`doctor --migrate-ledger`.
|
||||
- [x] PR 4 - `uninstall_oss.sh` core. Process shutdown, profile block removal
|
||||
(all shells), `uv tool uninstall`, idempotency + exit codes,
|
||||
`--dry-run`/`--json`.
|
||||
- [x] PR 5 - `omnigent uninstall` subcommand + gates. Python front, targets/
|
||||
flags, two-gate decision table, `--purge` backup-outside-target,
|
||||
external-config stripping.
|
||||
- [x] PR 6 - Docs + discovery. Installer next-steps + `--help` mention
|
||||
uninstall; README documents the standalone fallback and purge behavior.
|
||||
App-store and brew/apt-specific surfaces remain out of scope for this OSS
|
||||
CLI/script PR.
|
||||
|
||||
## Appendix A: ELI5
|
||||
|
||||
Omnigent is a houseguest.
|
||||
|
||||
- Installing = the guest moves in: hangs a coat by the door (the PATH line in
|
||||
your shell profile), keeps a box of their stuff in a closet (`~/.omnigent` -
|
||||
settings, logins, chat history) and a desk they work at (`~/omnigent`).
|
||||
Sometimes they borrow shared tools from your garage that may already have been
|
||||
there (uv, Node, tmux). Occasionally they leave a sticky note inside a
|
||||
roommate's notebook (config injected into other tools).
|
||||
- Uninstalling = the guest moves out politely:
|
||||
1. Finish what you're doing first. Stop working before packing (kill running
|
||||
daemons/runners) - don't yank the desk out while they're typing.
|
||||
2. Take only your own stuff. Grab your coat (remove only the marked PATH line,
|
||||
not random lines), take your box, erase your sticky note from the
|
||||
roommate's notebook.
|
||||
3. Don't take the shared tools. The garage drill might belong to the house.
|
||||
Just leave a note: "I think I brought this - you decide." Never haul it off
|
||||
on your own.
|
||||
4. Your box stays unless you say "throw it out." Moving out is not shredding
|
||||
your photos. Only if you explicitly say `--purge` does the box go - and
|
||||
even then it is boxed up in the garage first (a backup tarball OUTSIDE the
|
||||
room) so you can get it back.
|
||||
- The ledger = a move-in checklist the guest writes on arrival: "hung a coat
|
||||
here, borrowed this drill, left a note in that notebook." On move-out they
|
||||
read the checklist and undo exactly those things - no guessing.
|
||||
- Back-fill = for guests who moved in before checklists existed, walk the house
|
||||
and reconstruct the checklist from what you can see, writing down how sure you
|
||||
are ("coat on hook - definitely mine" vs "this drill - no idea who brought it,
|
||||
don't touch"). A reconstructed checklist never lets you auto-toss the risky
|
||||
stuff.
|
||||
- Bare uninstall = "show me what would happen first." Nothing changes until you
|
||||
add a destructive flag such as `--yes` or `--purge`.
|
||||
- `--yes` = "apply the previewed safe moves." It grabs the coat, but it still
|
||||
leaves the box unless you add `--purge`, and still will not erase a roommate's
|
||||
notebook unless you add `--modify-external-config`. Risky actions are gated by
|
||||
what you are touching, not by which checklist you have.
|
||||
|
||||
## Appendix B: Flowchart
|
||||
|
||||
```
|
||||
+-----------------------------+
|
||||
| omnigent uninstall [...] |
|
||||
| targets: cli | state | |
|
||||
| desktop-data | all |
|
||||
| flags: --purge --dry-run |
|
||||
| --yes --json --force |
|
||||
| --modify-external-config |
|
||||
+--------------+--------------+
|
||||
|
|
||||
+--------------v--------------+
|
||||
| Load install_ledger.json |
|
||||
+--------------+--------------+
|
||||
|
|
||||
+--------------------+--------------------+
|
||||
| | |
|
||||
ledger source=installer source=backfill NO ledger
|
||||
(real, trust) (evidence + per- |
|
||||
| field confidence) |
|
||||
| | v
|
||||
| | +----------------------+
|
||||
| | | Genuine install |
|
||||
| | | signal present? |
|
||||
| | | (installation_id / |
|
||||
| | | wheel / marker) |
|
||||
| | +-------+----------+----+
|
||||
| | no | yes |
|
||||
| | v v
|
||||
| | +------------+ +--------------+
|
||||
| | | Refuse: | | Back-fill |
|
||||
| | | nothing to | | from markers |
|
||||
| | | uninstall | | (read-only) |
|
||||
| | +------------+ +------+-------+
|
||||
+---------+----------+------------------------------+
|
||||
|
|
||||
v
|
||||
=====================================
|
||||
|| 1. PLAN/STOP PROCESSES FIRST ||
|
||||
|| dry-run reports planned stops; ||
|
||||
|| apply unloads LaunchAgents, then ||
|
||||
|| pidfiles/tmux -> SIGTERM/force ||
|
||||
=================+===================
|
||||
| won't stop? --> ABORT destructive steps (exit 2)
|
||||
v
|
||||
=====================================
|
||||
|| 2. --dry-run? -- yes -> print ||
|
||||
|| planned stops, paths, sizes, ||
|
||||
|| EXIT 0 ||
|
||||
=================+===================
|
||||
| no
|
||||
v
|
||||
+--------------------------------------------------+
|
||||
| For each planned action, apply the GATES: |
|
||||
| |
|
||||
| INTRINSIC-RISK gate (primary): |
|
||||
| - own + reversible (wheel, marked PATH block) |
|
||||
| -> auto under --yes |
|
||||
| - third-party file edit (injected config) |
|
||||
| -> needs --modify-external-config |
|
||||
| - data destruction (~/.omnigent, ~/omnigent) |
|
||||
| -> needs --purge (defaults to No) |
|
||||
| - shared deps (uv/Node/tmux, installed_by |
|
||||
| =unknown) -> REPORT ONLY, never remove |
|
||||
| |
|
||||
| CONFIDENCE gate (secondary, tighten-only): |
|
||||
| - inferred / low-confidence entry |
|
||||
| -> +1 notch friction, no auto under |
|
||||
| bare --yes (never loosens) |
|
||||
+----------------------+---------------------------+
|
||||
|
|
||||
v
|
||||
ORDER OF OPERATIONS (each gated above):
|
||||
+-------------------------------------------+
|
||||
| (processes already stopped) |
|
||||
| 3. Profile cleanup - remove ONLY delimited |
|
||||
| marker block, all shells incl. fish; |
|
||||
| back up profile; refuse if tampered |
|
||||
| 4. Strip injected external config (marker- |
|
||||
| scoped, ledger-recorded) |
|
||||
| 5. --purge? archive to backup tarball |
|
||||
| OUTSIDE target (~/.omnigent-backups/), |
|
||||
| then delete state; keep ~/omnigent |
|
||||
| unless --purge-workspace or confirm |
|
||||
| 6. uv tool uninstall omnigent (LAST) |
|
||||
+--------------------+----------------------+
|
||||
|
|
||||
v
|
||||
+--------------------------------------+
|
||||
| Idempotency by STATE-CHECK: |
|
||||
| already-absent = success (exit 0) |
|
||||
| tried & failed = report, non-zero, |
|
||||
| continue, summarize|
|
||||
| --json summary of what was done/kept |
|
||||
+--------------------------------------+
|
||||
|
||||
Other package surfaces:
|
||||
OS/package-manager uninstall owns package files. The Omnigent
|
||||
uninstaller handles local profile/state cleanup and uses
|
||||
uv tool uninstall for uv-installed wheels; it does not remove
|
||||
shared dependencies or act as a cross-domain reaper.
|
||||
```
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 120 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 759 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 129 KiB |
@@ -7,7 +7,7 @@ instead of a human hand-maintaining a spreadsheet and hoping it still reflects
|
||||
reality.
|
||||
|
||||
> **Status:** shipped and in use. The bench on `main` has three transport
|
||||
> drivers, six P0 probes, five report-only P1 probes, automatic live/offline
|
||||
> drivers, six P0 probes, four report-only P1 probes, automatic live/offline
|
||||
> selection, and a capability-derived matrix that has already caught and
|
||||
> corrected real declaration drift. See
|
||||
> [Current state](#current-state-shipped) for what is live vs. still open. The
|
||||
@@ -202,9 +202,7 @@ Validated for presence and shape only: `Owner`, `Transport`, `Implementation`,
|
||||
| Dimension | How the probe proves it |
|
||||
|---|---|
|
||||
| Basic turn (P0 prerequisite) | complete a marker-echo turn and require assistant text |
|
||||
| Fork replay (P1) | clone the session after Basic turn, require copied marker history, and require the clone to recall it |
|
||||
| Streaming (P0) | count output-text deltas; repeated single-delta output is `PARTIAL` |
|
||||
| Reasoning (P1) | request high effort and require a forwarded reasoning delta or persisted reasoning item; no observation is inconclusive because the model may emit none |
|
||||
| Tool calling (P0) | provoke the transport's tool mechanism and require a surfaced call |
|
||||
| Omnigent MCP (P1, native only) | call read-only `sys_session_list` through the generated `omnigent` MCP relay and require a matching function-call item |
|
||||
| Policy DENY (P0) | apply a tool-call deny and require a blocked-call signal |
|
||||
@@ -214,25 +212,12 @@ Validated for presence and shape only: `Owner`, `Transport`, `Implementation`,
|
||||
| Cost tracking (P1) | read priced cost or token usage from the turn/session |
|
||||
| Interrupt (P0) | interrupt a long turn and require cancellation or early termination |
|
||||
|
||||
Planned dimensions are steering, live queue, resume, images, and compaction.
|
||||
|
||||
Their declarations already have a place in `HarnessCapabilities`: resume uses
|
||||
the `Resume` mechanism enum, while steering, live queue, images, and compaction
|
||||
are optional booleans. An unset optional value makes no claim and therefore
|
||||
stays `UNKNOWN` until the corresponding probe work establishes the harness's
|
||||
expected behavior.
|
||||
Planned dimensions are steering, live queue, resume/fork, reasoning, images,
|
||||
and compaction.
|
||||
|
||||
Every behavioral probe also reads the corresponding declared flag and returns
|
||||
`DRIFT` when observed disagrees with declared.
|
||||
|
||||
The CLI can slice this catalog with repeatable or comma-separated
|
||||
`--dimension` values. A slice always includes `basic_turn` because it proves
|
||||
the harness is exercisable before interpreting another probe's result. Reports
|
||||
and the live Rich grid contain only the selected columns. Each repeated
|
||||
`--harness NAME[=MODEL]` binds an optional model override directly to that
|
||||
harness, avoiding both test model-pool environment variables and positional
|
||||
cross-family assignment. Omitting `=MODEL` keeps that profile's default.
|
||||
|
||||
### Illustrative probe shape
|
||||
|
||||
```python
|
||||
@@ -282,7 +267,7 @@ The bench on `main` includes:
|
||||
|
||||
- **Six P0 probes:** Basic turn, Streaming, Tool calling, Policy DENY, Model
|
||||
override, and Interrupt.
|
||||
- **Six P1 probes:** Fork replay, Reasoning, Omnigent MCP, Policy ALLOW, Policy ASK, and Cost tracking. P1 verdicts
|
||||
- **Four P1 probes:** Omnigent MCP, Policy ALLOW, Policy ASK, and Cost tracking. P1 verdicts
|
||||
are report-only and do not gate the same way as P0 declarations.
|
||||
- **Three transport drivers:** `full-server`, `native-tui`, and `sdk-inproc`,
|
||||
selected by harness family with `--transport` and `--fast` overrides.
|
||||
@@ -303,7 +288,7 @@ The bench on `main` includes:
|
||||
### Not yet wired
|
||||
|
||||
- Registry-driven server seeding for community native UI agents.
|
||||
- Steering, live queue, resume, images, and compaction probes.
|
||||
- Steering, live queue, resume/fork, reasoning, images, and compaction probes.
|
||||
- Automatic provisioning of vendor login/provider configuration for native
|
||||
harnesses; unavailable environments skip cleanly.
|
||||
|
||||
@@ -365,8 +350,7 @@ stream, the bench flags a real drift on the next run, rather than a false
|
||||
|
||||
| Dimension | `sdk-inproc` (`--fast`) | `full-server` (SDK default) | `native-tui` |
|
||||
|---|---|---|---|
|
||||
| Basic turn, Streaming, Reasoning, Model override, Interrupt | Wrap-level observation; reasoning effort is set per request | End-to-end server/runner observation; reasoning effort is set on the session | End-to-end server/runner/vendor observation; reasoning effort is set on the session |
|
||||
| Fork replay | Not observable | Clone + copied-history replay through server/runner | Clone + copied-history replay through server/runner/vendor |
|
||||
| Basic turn, Streaming, Model override, Interrupt | Wrap-level observation | End-to-end server/runner observation | End-to-end server/runner/vendor observation |
|
||||
| Tool calling | Request-level wrap tool | Server-dispatched builtin | Vendor tool mirrored into session items |
|
||||
| Omnigent MCP | Not applicable | Not applicable | Generated `omnigent` MCP relay when supported by the vendor |
|
||||
| Policy DENY | Not observable | Fixed policy blocks the builtin | Session CEL policy triggers the native policy hook |
|
||||
@@ -439,5 +423,5 @@ agree with it.
|
||||
- **Per-harness native provisioning** — some vendors require login or provider
|
||||
configuration that the bench deliberately cannot create. Improve diagnostics
|
||||
where possible while retaining clean skips.
|
||||
- **Additional dimensions** — steering, live queue, resume, images, and
|
||||
compaction.
|
||||
- **Additional dimensions** — steering, live queue, resume/fork, reasoning,
|
||||
images, and compaction.
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# AWS Analyst
|
||||
|
||||
An example Omnigent agent that answers questions over **governed AWS data** through
|
||||
the official [AWS Labs MCP servers](https://github.com/awslabs/mcp) — no custom
|
||||
connector code required. It shows how any AWS Labs MCP server plugs into Omnigent as
|
||||
a `type: mcp` tool.
|
||||
|
||||
Wired connectors (both **read-only** by default):
|
||||
|
||||
| Connector | AWS Labs server | Tools surfaced |
|
||||
|---|---|---|
|
||||
| `redshift` | `awslabs.redshift-mcp-server` | `list_clusters`, `list_databases`, `list_schemas`, `list_tables`, `list_columns`, `execute_query` |
|
||||
| `s3-tables` | `awslabs.s3-tables-mcp-server` | metadata discovery + read-only SQL |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [`uv`/`uvx`](https://docs.astral.sh/uv/) on `PATH` — the AWS Labs servers are
|
||||
published to PyPI as `awslabs.*` and launched via `uvx ...@latest`.
|
||||
- AWS credentials the servers can resolve: an `AWS_PROFILE` + `AWS_REGION`, or an
|
||||
IAM role on the host.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
AWS_PROFILE=my-profile AWS_REGION=us-east-1 omnigent run examples/aws_analyst
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The S3 Tables server defaults to read-only; this recipe intentionally does **not**
|
||||
pass `--allow-write`.
|
||||
- The `tools:` allow-list on the Redshift connector limits what the model can call —
|
||||
a good default for a governed analytics agent.
|
||||
- Pairs naturally with a Databricks Genie connector for a Databricks-on-AWS
|
||||
"better together" analyst that reasons across both platforms.
|
||||
@@ -1,70 +0,0 @@
|
||||
# AWS Analyst — query governed AWS data through official awslabs MCP servers.
|
||||
#
|
||||
# This example agent wires two AWS Labs MCP servers as Omnigent connectors:
|
||||
# - Amazon Redshift (awslabs.redshift-mcp-server)
|
||||
# - Amazon S3 Tables (awslabs.s3-tables-mcp-server)
|
||||
# Both run read-only by default. The agent uses them to answer analytical
|
||||
# questions over data governed in AWS — a natural companion to Databricks Genie
|
||||
# in a Databricks-on-AWS "better together" setup.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - `uvx` on PATH (the awslabs servers are published to PyPI as awslabs.*).
|
||||
# - AWS credentials resolvable by the servers (AWS_PROFILE + AWS_REGION, or a role).
|
||||
#
|
||||
# Usage:
|
||||
# AWS_PROFILE=my-profile AWS_REGION=us-east-1 omnigent run examples/aws_analyst
|
||||
|
||||
spec_version: 1
|
||||
name: aws_analyst
|
||||
description: >-
|
||||
An analyst agent that answers questions over governed AWS data — Amazon Redshift
|
||||
and Amazon S3 Tables — through the official awslabs MCP servers (read-only).
|
||||
|
||||
executor:
|
||||
type: omnigent
|
||||
config:
|
||||
harness: claude-sdk
|
||||
|
||||
tools:
|
||||
# Amazon Redshift — discovery + read-only SQL over your clusters.
|
||||
redshift:
|
||||
type: mcp
|
||||
command: uvx
|
||||
args: [awslabs.redshift-mcp-server@latest]
|
||||
env:
|
||||
AWS_PROFILE: ${AWS_PROFILE}
|
||||
AWS_REGION: ${AWS_REGION}
|
||||
FASTMCP_LOG_LEVEL: INFO
|
||||
# Allow-list: only these tools are surfaced to the model. execute_query is
|
||||
# read-only on the server side; the discovery tools let the agent map the
|
||||
# environment before querying.
|
||||
tools: [list_clusters, list_databases, list_schemas, list_tables, list_columns, execute_query]
|
||||
|
||||
# Amazon S3 Tables — read-only metadata discovery + SQL over table buckets.
|
||||
# (Server defaults to read-only; --allow-write is intentionally NOT set.)
|
||||
s3-tables:
|
||||
type: mcp
|
||||
command: uvx
|
||||
args: [awslabs.s3-tables-mcp-server@latest]
|
||||
env:
|
||||
AWS_PROFILE: ${AWS_PROFILE}
|
||||
AWS_REGION: ${AWS_REGION}
|
||||
|
||||
prompt: |
|
||||
You are an AWS data analyst. You answer questions over governed AWS data using
|
||||
two toolsets:
|
||||
|
||||
- `redshift` — Amazon Redshift. Start by discovering the environment
|
||||
(list_clusters → list_databases → list_schemas → list_tables → list_columns)
|
||||
before writing SQL, then use execute_query for read-only analytical queries.
|
||||
- `s3-tables` — Amazon S3 Tables. Use it for metadata discovery and read-only
|
||||
SQL over table buckets.
|
||||
|
||||
Rules:
|
||||
- Prefer discovery before querying; never assume a table or column exists —
|
||||
confirm it with the list_* tools first.
|
||||
- These tools are read-only. Do not attempt inserts, updates, or deletes.
|
||||
- Always state which source (Redshift or S3 Tables) and which table an answer
|
||||
came from, so results are auditable.
|
||||
- When a question spans multiple tables, explain your join logic before running
|
||||
the query.
|
||||
@@ -22,8 +22,6 @@ prompt: |
|
||||
contract.
|
||||
- Make the change, then drive it to green: run the relevant tests, lint, and
|
||||
typecheck for the code you touched.
|
||||
- When you report test results, include the exact command and file set. If
|
||||
you mention counts, distinguish collected test cases from test functions.
|
||||
- Co-sign every commit you author: end each commit message with a blank line
|
||||
followed by this exact trailer as its final line —
|
||||
`Co-authored-by: omnigent <noreply@omnigent.ai>`
|
||||
|
||||
@@ -22,8 +22,6 @@ prompt: |
|
||||
contract.
|
||||
- Make the change, then drive it to green: run the relevant tests, lint, and
|
||||
typecheck for the code you touched.
|
||||
- When you report test results, include the exact command and file set. If
|
||||
you mention counts, distinguish collected test cases from test functions.
|
||||
- Co-sign every commit you author: end each commit message with a blank line
|
||||
followed by this exact trailer as its final line —
|
||||
`Co-authored-by: omnigent <noreply@omnigent.ai>`
|
||||
|
||||
@@ -3,23 +3,14 @@ name: cursor
|
||||
description: Cursor coding sub-agent — implements, cross-vendor reviews, or explores a scoped task in its own worktree.
|
||||
|
||||
# Native Cursor TUI harness (`cursor-agent`): runs in its own terminal the
|
||||
# human can open in the UI's Subagents panel and TAKE OVER. Headless workers
|
||||
# can't answer ApprovalCards, so YOLO skips cursor-agent's in-terminal
|
||||
# prompts (and the mirrored web cards). Omnigent ``blast_radius`` still
|
||||
# DENYs the catastrophic set. Opt out with ``yolo: false``; or set
|
||||
# ``permission_mode: auto`` for Smart Auto (``--auto-review``) instead.
|
||||
# human can open in the UI's Subagents panel and TAKE OVER. cursor-agent owns
|
||||
# its own tool-approval gating (omnigent does not intercept it), so dangerous
|
||||
# actions surface in the cursor TUI / mirrored web cards rather than being
|
||||
# auto-bypassed.
|
||||
executor:
|
||||
type: omnigent
|
||||
# Faster default for Polly Cursor workers; override per-session with ``/model``.
|
||||
# Use the base id from Cursor's model list / SDK (``grok-4.5``). The compound
|
||||
# ``cursor-grok-4.5-high`` also works on cursor-agent, but the SDK catalog
|
||||
# exposes ``grok-4.5``.
|
||||
model: grok-4.5
|
||||
config:
|
||||
harness: cursor-native
|
||||
# YOLO: headless workers can't answer approval prompts, so run
|
||||
# cursor-agent with full bypass (``--yolo``).
|
||||
yolo: true
|
||||
|
||||
prompt: |
|
||||
You are Cursor, a coding sub-agent dispatched by the polly
|
||||
@@ -32,8 +23,6 @@ prompt: |
|
||||
contract.
|
||||
- Make the change, then drive it to green: run the relevant tests, lint, and
|
||||
typecheck for the code you touched.
|
||||
- When you report test results, include the exact command and file set. If
|
||||
you mention counts, distinguish collected test cases from test functions.
|
||||
- Co-sign every commit you author: end each commit message with a blank line
|
||||
followed by this exact trailer as its final line —
|
||||
`Co-authored-by: omnigent <noreply@omnigent.ai>`
|
||||
|
||||
@@ -22,8 +22,6 @@ prompt: |
|
||||
contract.
|
||||
- Make the change, then drive it to green: run the relevant tests, lint, and
|
||||
typecheck for the code you touched.
|
||||
- When you report test results, include the exact command and file set. If
|
||||
you mention counts, distinguish collected test cases from test functions.
|
||||
- Co-sign every commit you author: end each commit message with a blank line
|
||||
followed by this exact trailer as its final line —
|
||||
`Co-authored-by: omnigent <noreply@omnigent.ai>`
|
||||
|
||||
@@ -23,8 +23,6 @@ prompt: |
|
||||
contract.
|
||||
- Make the change, then drive it to green: run the relevant tests, lint, and
|
||||
typecheck for the code you touched.
|
||||
- When you report test results, include the exact command and file set. If
|
||||
you mention counts, distinguish collected test cases from test functions.
|
||||
- Co-sign every commit you author: end each commit message with a blank line
|
||||
followed by this exact trailer as its final line —
|
||||
`Co-authored-by: omnigent <noreply@omnigent.ai>`
|
||||
|
||||
@@ -21,8 +21,6 @@ prompt: |
|
||||
contract.
|
||||
- Make the change, then drive it to green: run the relevant tests, lint, and
|
||||
typecheck for the code you touched.
|
||||
- When you report test results, include the exact command and file set. If
|
||||
you mention counts, distinguish collected test cases from test functions.
|
||||
- Co-sign every commit you author: end each commit message with a blank line
|
||||
followed by this exact trailer as its final line —
|
||||
`Co-authored-by: omnigent <noreply@omnigent.ai>`
|
||||
|
||||
@@ -24,7 +24,7 @@ spawn: true
|
||||
# (`omnigent setup --no-internal-beta`) — an Anthropic API key, a Claude
|
||||
# subscription, an OpenAI-compatible gateway, or a Databricks workspace. With
|
||||
# no model named the claude-sdk harness resolves the configured provider's
|
||||
# default Claude model.
|
||||
# default Claude model (the bundled catalog default is claude-opus-4-8).
|
||||
executor:
|
||||
type: omnigent
|
||||
context_window: 1000000
|
||||
@@ -178,16 +178,6 @@ prompt: |
|
||||
write or edit source code or tests, run a deep code investigation for your own
|
||||
answer, or merge a PR — those go to sub-agents.
|
||||
|
||||
Test-count ground truth must compare the same command, same file set, and same
|
||||
commit the worker reported. For pytest, collected CASES are the count: use
|
||||
`python -m pytest --collect-only -q <same files>` when reconciling a reported
|
||||
total, and never use `grep -c 'def test_'` as a correctness oracle. A single
|
||||
test function can expand into many collected cases via parametrized tests, and
|
||||
a one-file function count cannot be compared to a multi-file gate. Do not record
|
||||
`miscount`, `over-report`, or `fabrication` in `.polly/registry.json` or a
|
||||
handoff unless you have re-collected the same gate at the same commit and the
|
||||
numbers still disagree.
|
||||
|
||||
For long-running processes that can't block a single tool call (a local dev
|
||||
server on localhost:PORT, file watchers, tailing logs) or ad-hoc shell where
|
||||
`sys_os_shell`'s one-shot blocking model doesn't fit, launch the `shell`
|
||||
|
||||
@@ -15,11 +15,6 @@ anyone needs to read through.
|
||||
2. Run the deterministic gates first — tests / lint / typecheck via
|
||||
`sys_os_shell`. If red, re-dispatch the implementer to drive it green first;
|
||||
don't involve the reviewer yet.
|
||||
If a pytest result's count must be recorded or reconciled, collect ground
|
||||
truth with `python -m pytest --collect-only -q <same files>` against the
|
||||
exact file set/command/commit the implementer reported. Never use
|
||||
`grep -c 'def test_'` as a pytest count: it counts functions, not collected
|
||||
cases, and misses parametrized case expansion.
|
||||
3. Dispatch a DIFFERENT-vendor sub-agent as reviewer: pick any AVAILABLE worker
|
||||
whose vendor differs from the implementer's — `claude_code`, `codex`,
|
||||
`opencode`, `cursor`, `hermes`, or `pi` (e.g. Claude built it → any of
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# Memory is keyed by the agent id, so all of Remy's runs share one memory bank.
|
||||
#
|
||||
# Setup:
|
||||
# pip install 'omnigent[hindsight]'
|
||||
# pip install 'omnigent[memory]'
|
||||
# export HINDSIGHT_API_KEY=hsk_... # https://ui.hindsight.vectorize.io
|
||||
#
|
||||
# Usage:
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
SLACK_BOT_TOKEN=xoxb-your-bot-token
|
||||
SLACK_APP_TOKEN=xapp-your-app-level-token
|
||||
OMNIGENT_AGENT_NAME=your_agent_name
|
||||
|
||||
# Optional. Defaults to the local Omnigent server from docs/api-1.yaml.
|
||||
OMNIGENT_BASE_URL=http://127.0.0.1:6767
|
||||
|
||||
# Optional Omnigent auth modes.
|
||||
# OMNIGENT_AUTH_EMAIL=slack-bot@example.com
|
||||
# OMNIGENT_AUTH_HEADER_NAME=X-Forwarded-Email
|
||||
# OMNIGENT_SESSION_COOKIE=ap_session=...
|
||||
|
||||
# Optional runner fallback. If no online runner exists, launch one on a host.
|
||||
# Defaults to the bot process current working directory.
|
||||
# OMNIGENT_RUNNER_WORKSPACE=/absolute/path/to/workspace
|
||||
# OMNIGENT_RUNNER_HOST_ID=host_optional_specific_host
|
||||
# OMNIGENT_RUNNER_LAUNCH_TIMEOUT_SECONDS=60
|
||||
|
||||
# Optional runtime tuning.
|
||||
# LOG_LEVEL=INFO
|
||||
# OMNIGENT_SLACK_DATABASE_PATH=data/omnigent_slack.sqlite3
|
||||
# SLACK_UPDATE_INTERVAL_SECONDS=1.0
|
||||
@@ -1,10 +0,0 @@
|
||||
.env
|
||||
.venv/
|
||||
.uv-cache/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
data/*.sqlite3
|
||||
data/*.sqlite3-*
|
||||
@@ -1 +0,0 @@
|
||||
3.12
|
||||
@@ -1,39 +0,0 @@
|
||||
# Omnigent Slack Bot
|
||||
|
||||
Slack Socket Mode bot that maps one Slack thread to one Omnigent session.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create a Slack app with Socket Mode enabled.
|
||||
2. Add bot scopes for `app_mentions:read`, `chat:write`, and the history scopes needed for the channel types where the bot will run.
|
||||
3. Install the app into the workspace.
|
||||
4. Copy `.env.example` to `.env` and fill in Slack and Omnigent values.
|
||||
5. Run the bot:
|
||||
|
||||
```bash
|
||||
UV_CACHE_DIR=.uv-cache uv run omnigent-slack
|
||||
```
|
||||
|
||||
Set `LOG_LEVEL=DEBUG` in `.env` when diagnosing why Slack events are not producing replies.
|
||||
|
||||
If Omnigent has no online runners, the bot launches one on an online host using
|
||||
the current working directory as the workspace. Set `OMNIGENT_RUNNER_WORKSPACE`
|
||||
when the host needs a different absolute path.
|
||||
|
||||
Mention the bot with a message to start a session:
|
||||
|
||||
```text
|
||||
@your-bot help me inspect this failure
|
||||
```
|
||||
|
||||
Replies in that Slack thread continue the same Omnigent session.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
UV_CACHE_DIR=.uv-cache uv run pytest
|
||||
UV_CACHE_DIR=.uv-cache uv run ruff check
|
||||
UV_CACHE_DIR=.uv-cache uv run mypy src
|
||||
```
|
||||
|
||||
The Omnigent API reference used for implementation is stored at `docs/api-1.yaml`.
|
||||
@@ -1,56 +0,0 @@
|
||||
[project]
|
||||
name = "omnigent-slack"
|
||||
version = "0.1.0"
|
||||
description = "Slack Socket Mode bot that drives Omnigent sessions."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"aiosqlite>=0.21.0",
|
||||
"aiohttp>=3.12.0",
|
||||
"httpx>=0.28.0",
|
||||
"pydantic-settings>=2.10.0",
|
||||
"python-dotenv>=1.1.0",
|
||||
"slack-bolt>=1.23.0",
|
||||
"markdown-to-mrkdwn>=0.3.3",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
omnigent-slack = "omnigent_slack.__main__:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"mypy>=1.16.0",
|
||||
"pytest>=8.4.0",
|
||||
"pytest-asyncio>=1.0.0",
|
||||
"respx>=0.22.0",
|
||||
"ruff>=0.12.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.8.0,<0.9.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
exclude = [".uv-cache", ".venv", "docs"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "ASYNC"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
strict = true
|
||||
warn_unreachable = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"slack_bolt.*",
|
||||
"slack_sdk.*",
|
||||
"markdown_to_mrkdwn.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Slack bot for Omnigent sessions."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -1,13 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from omnigent_slack.app import run
|
||||
|
||||
|
||||
def main() -> None:
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,114 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from slack_bolt.adapter.socket_mode.aiohttp import AsyncSocketModeHandler
|
||||
from slack_bolt.async_app import AsyncApp
|
||||
|
||||
from omnigent_slack.config import load_settings
|
||||
from omnigent_slack.omnigent import OmnigentAuth, OmnigentClient
|
||||
from omnigent_slack.service import SlackOmnigentService
|
||||
from omnigent_slack.store import SQLiteStore
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
load_dotenv()
|
||||
settings = load_settings()
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(
|
||||
"Starting Omnigent Slack bot base_url=%s database=%s runner_workspace=%s",
|
||||
settings.omnigent_base_url,
|
||||
settings.database_path,
|
||||
settings.omnigent_runner_workspace,
|
||||
)
|
||||
|
||||
store = SQLiteStore(settings.database_path)
|
||||
await store.initialize()
|
||||
|
||||
omnigent = OmnigentClient(
|
||||
base_url=str(settings.omnigent_base_url),
|
||||
auth=OmnigentAuth(
|
||||
email=settings.omnigent_auth_email,
|
||||
header_name=settings.omnigent_auth_header_name,
|
||||
session_cookie=settings.omnigent_session_cookie,
|
||||
),
|
||||
runner_workspace=settings.omnigent_runner_workspace,
|
||||
runner_host_id=settings.omnigent_runner_host_id,
|
||||
runner_launch_timeout_seconds=settings.omnigent_runner_launch_timeout_seconds,
|
||||
)
|
||||
logger.info("Checking Omnigent server availability base_url=%s", settings.omnigent_base_url)
|
||||
try:
|
||||
agents = await omnigent.list_agents()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Omnigent server is not reachable at %s; aborting startup", settings.omnigent_base_url
|
||||
)
|
||||
await omnigent.aclose()
|
||||
raise
|
||||
logger.info("Omnigent server is up; found %s built-in agents", len(agents))
|
||||
|
||||
agent_id = _resolve_agent_id(agents, settings.omnigent_agent_name)
|
||||
if agent_id is None:
|
||||
available = ", ".join(sorted(str(a.get("name")) for a in agents if a.get("name"))) or "none"
|
||||
await omnigent.aclose()
|
||||
raise RuntimeError(
|
||||
f"No Omnigent agent named {settings.omnigent_agent_name!r} was found. "
|
||||
f"Available agents: {available}"
|
||||
)
|
||||
logger.info("Resolved Omnigent agent name=%s to id=%s", settings.omnigent_agent_name, agent_id)
|
||||
|
||||
service = SlackOmnigentService(
|
||||
store=store,
|
||||
omnigent=omnigent,
|
||||
omnigent_agent_id=agent_id,
|
||||
update_interval_seconds=settings.slack_update_interval_seconds,
|
||||
)
|
||||
|
||||
app = AsyncApp(token=settings.slack_bot_token)
|
||||
register_handlers(app, service)
|
||||
|
||||
handler = AsyncSocketModeHandler(app, settings.slack_app_token)
|
||||
try:
|
||||
logger.info("Connecting to Slack Socket Mode")
|
||||
await handler.start_async() # type: ignore[no-untyped-call]
|
||||
finally:
|
||||
logger.info("Shutting down Omnigent Slack bot")
|
||||
await service.shutdown()
|
||||
await omnigent.aclose()
|
||||
|
||||
|
||||
def _resolve_agent_id(agents: list[dict[str, Any]], agent_name: str) -> str | None:
|
||||
for agent in agents:
|
||||
if agent.get("name") == agent_name:
|
||||
agent_id = agent.get("id")
|
||||
if isinstance(agent_id, str):
|
||||
return agent_id
|
||||
return None
|
||||
|
||||
|
||||
def register_handlers(app: AsyncApp, service: SlackOmnigentService) -> None:
|
||||
@app.event("app_mention")
|
||||
async def handle_app_mention(
|
||||
body: dict[str, Any],
|
||||
event: dict[str, Any],
|
||||
client: Any,
|
||||
context: dict[str, Any],
|
||||
) -> None:
|
||||
await service.handle_app_mention(body=body, event=event, client=client, context=context)
|
||||
|
||||
@app.event("message")
|
||||
async def handle_message(
|
||||
body: dict[str, Any],
|
||||
event: dict[str, Any],
|
||||
client: Any,
|
||||
context: dict[str, Any],
|
||||
) -> None:
|
||||
if not body.get("team_id") and not event.get("team"):
|
||||
return
|
||||
await service.handle_message(body=body, event=event, client=client, context=context)
|
||||
@@ -1,61 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
slack_bot_token: str = Field(validation_alias="SLACK_BOT_TOKEN")
|
||||
slack_app_token: str = Field(validation_alias="SLACK_APP_TOKEN")
|
||||
omnigent_agent_name: str = Field(validation_alias="OMNIGENT_AGENT_NAME")
|
||||
|
||||
omnigent_base_url: str = Field(
|
||||
default="http://127.0.0.1:6767",
|
||||
validation_alias="OMNIGENT_BASE_URL",
|
||||
)
|
||||
omnigent_auth_email: str | None = Field(default=None, validation_alias="OMNIGENT_AUTH_EMAIL")
|
||||
omnigent_auth_header_name: str = Field(
|
||||
default="X-Forwarded-Email",
|
||||
validation_alias="OMNIGENT_AUTH_HEADER_NAME",
|
||||
)
|
||||
omnigent_session_cookie: str | None = Field(
|
||||
default=None,
|
||||
validation_alias="OMNIGENT_SESSION_COOKIE",
|
||||
)
|
||||
omnigent_runner_workspace: str = Field(
|
||||
default_factory=lambda: str(Path.cwd()),
|
||||
validation_alias="OMNIGENT_RUNNER_WORKSPACE",
|
||||
)
|
||||
omnigent_runner_host_id: str | None = Field(
|
||||
default=None,
|
||||
validation_alias="OMNIGENT_RUNNER_HOST_ID",
|
||||
)
|
||||
omnigent_runner_launch_timeout_seconds: float = Field(
|
||||
default=60.0,
|
||||
ge=1.0,
|
||||
validation_alias="OMNIGENT_RUNNER_LAUNCH_TIMEOUT_SECONDS",
|
||||
)
|
||||
|
||||
database_path: Path = Field(
|
||||
default=Path("data/omnigent_slack.sqlite3"),
|
||||
validation_alias="OMNIGENT_SLACK_DATABASE_PATH",
|
||||
)
|
||||
log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL")
|
||||
slack_update_interval_seconds: float = Field(
|
||||
default=1.0,
|
||||
ge=0.0,
|
||||
validation_alias="SLACK_UPDATE_INTERVAL_SECONDS",
|
||||
)
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
return Settings() # type: ignore[call-arg]
|
||||
@@ -1,70 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from omnigent_slack.models import SlackTurn, ThreadKey
|
||||
|
||||
TurnWorker = Callable[[SlackTurn], Awaitable[None]]
|
||||
|
||||
|
||||
class ThreadTurnDispatcher:
|
||||
def __init__(self, worker: TurnWorker, idle_timeout_seconds: float = 60.0) -> None:
|
||||
self._worker = worker
|
||||
self._idle_timeout_seconds = idle_timeout_seconds
|
||||
self._queues: dict[ThreadKey, asyncio.Queue[SlackTurn]] = {}
|
||||
self._tasks: dict[ThreadKey, asyncio.Task[None]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._logger = logging.getLogger(__name__)
|
||||
|
||||
async def enqueue(self, turn: SlackTurn) -> None:
|
||||
async with self._lock:
|
||||
queue = self._queues.get(turn.key)
|
||||
if queue is None:
|
||||
queue = asyncio.Queue()
|
||||
self._queues[turn.key] = queue
|
||||
self._tasks[turn.key] = asyncio.create_task(self._run_queue(turn.key, queue))
|
||||
self._logger.debug("Created turn queue for %s", turn.key.display())
|
||||
await queue.put(turn)
|
||||
self._logger.info(
|
||||
"Queued Slack turn thread=%s queue_size=%s create_if_missing=%s",
|
||||
turn.key.display(),
|
||||
queue.qsize(),
|
||||
turn.create_if_missing,
|
||||
)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
async with self._lock:
|
||||
tasks = list(self._tasks.values())
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
async def _run_queue(self, key: ThreadKey, queue: asyncio.Queue[SlackTurn]) -> None:
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
turn = await asyncio.wait_for(queue.get(), timeout=self._idle_timeout_seconds)
|
||||
except TimeoutError:
|
||||
self._logger.debug("Closing idle turn queue for %s", key.display())
|
||||
return
|
||||
try:
|
||||
self._logger.info("Running queued Slack turn thread=%s", key.display())
|
||||
await self._worker(turn)
|
||||
except Exception:
|
||||
self._logger.exception("Slack turn failed for %s", key.display())
|
||||
finally:
|
||||
queue.task_done()
|
||||
finally:
|
||||
async with self._lock:
|
||||
if self._queues.get(key) is queue:
|
||||
if queue.empty():
|
||||
self._queues.pop(key, None)
|
||||
self._tasks.pop(key, None)
|
||||
else:
|
||||
# A turn slipped in after the idle timeout fired but
|
||||
# before this teardown reacquired the lock. The queue
|
||||
# stays registered, so no future enqueue would spawn a
|
||||
# worker — re-arm one here to keep draining it.
|
||||
self._tasks[key] = asyncio.create_task(self._run_queue(key, queue))
|
||||
@@ -1,30 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ThreadKey:
|
||||
team_id: str
|
||||
channel_id: str
|
||||
thread_ts: str
|
||||
|
||||
@classmethod
|
||||
def from_event(cls, team_id: str, event: dict[str, object]) -> ThreadKey:
|
||||
channel_id = str(event["channel"])
|
||||
thread_ts = str(event.get("thread_ts") or event["ts"])
|
||||
return cls(team_id=team_id, channel_id=channel_id, thread_ts=thread_ts)
|
||||
|
||||
def display(self) -> str:
|
||||
return f"{self.team_id}:{self.channel_id}:{self.thread_ts}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SlackTurn:
|
||||
key: ThreadKey
|
||||
text: str
|
||||
user_id: str
|
||||
create_if_missing: bool
|
||||
title: str
|
||||
slack_client: Any
|
||||
@@ -1,570 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class OmnigentError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class RunnerUnavailableError(OmnigentError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OmnigentAuth:
|
||||
email: str | None = None
|
||||
header_name: str = "X-Forwarded-Email"
|
||||
session_cookie: str | None = None
|
||||
|
||||
def headers(self) -> dict[str, str]:
|
||||
headers: dict[str, str] = {}
|
||||
if self.email:
|
||||
headers[self.header_name] = self.email
|
||||
if self.session_cookie:
|
||||
headers["Cookie"] = (
|
||||
self.session_cookie
|
||||
if "=" in self.session_cookie
|
||||
else f"ap_session={self.session_cookie}"
|
||||
)
|
||||
return headers
|
||||
|
||||
|
||||
class OmnigentClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
auth: OmnigentAuth | None = None,
|
||||
timeout: float = 30.0,
|
||||
runner_workspace: str | None = None,
|
||||
runner_host_id: str | None = None,
|
||||
runner_launch_timeout_seconds: float = 60.0,
|
||||
) -> None:
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=base_url.rstrip("/"),
|
||||
timeout=httpx.Timeout(timeout, read=None),
|
||||
headers=(auth or OmnigentAuth()).headers(),
|
||||
)
|
||||
self._runner_workspace = runner_workspace
|
||||
self._runner_host_id = runner_host_id
|
||||
self._runner_launch_timeout_seconds = runner_launch_timeout_seconds
|
||||
self._logger = logging.getLogger(__name__)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
async def create_session(self, agent_id: str, title: str) -> str:
|
||||
self._logger.info("Creating Omnigent session agent_id=%s title=%r", agent_id, title)
|
||||
response = await self._client.post(
|
||||
"/v1/sessions",
|
||||
json={"agent_id": agent_id, "title": title},
|
||||
)
|
||||
await _raise_for_status(response)
|
||||
payload = response.json()
|
||||
session_id = _extract_session_id(payload)
|
||||
if session_id is None:
|
||||
raise OmnigentError(f"Create session response did not include an id: {payload!r}")
|
||||
self._logger.info("Created Omnigent session session_id=%s", session_id)
|
||||
return session_id
|
||||
|
||||
async def submit_message(self, session_id: str, text: str) -> None:
|
||||
self._logger.info(
|
||||
"Submitting Slack message to Omnigent session_id=%s chars=%s",
|
||||
session_id,
|
||||
len(text),
|
||||
)
|
||||
payload = {
|
||||
"type": "message",
|
||||
"data": {
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": text}],
|
||||
},
|
||||
}
|
||||
response = await self._client.post(f"/v1/sessions/{session_id}/events", json=payload)
|
||||
await _raise_for_status(response)
|
||||
self._logger.debug("Submitted Omnigent message session_id=%s", session_id)
|
||||
|
||||
async def bind_random_runner(self, session_id: str) -> str:
|
||||
runner_ids = await self.list_runner_ids()
|
||||
if not runner_ids:
|
||||
return await self.launch_random_runner(session_id)
|
||||
|
||||
runner_id = random.choice(runner_ids)
|
||||
self._logger.info(
|
||||
"Binding random Omnigent runner session_id=%s runner_id=%s candidates=%s",
|
||||
session_id,
|
||||
runner_id,
|
||||
len(runner_ids),
|
||||
)
|
||||
response = await self._client.patch(
|
||||
f"/v1/sessions/{session_id}",
|
||||
json={"runner_id": runner_id},
|
||||
)
|
||||
await _raise_for_status(response)
|
||||
return runner_id
|
||||
|
||||
async def list_runner_ids(self) -> list[str]:
|
||||
runner_ids = await self.list_runner_ids_from_hosts()
|
||||
self._logger.info("Loaded Omnigent runner ids from hosts count=%s", len(runner_ids))
|
||||
return runner_ids
|
||||
|
||||
async def launch_random_runner(self, session_id: str) -> str:
|
||||
if not self._runner_workspace:
|
||||
raise OmnigentError(
|
||||
"No online Omnigent runners are available, and runner auto-launch is not "
|
||||
"configured. Set OMNIGENT_RUNNER_WORKSPACE to an absolute workspace path; "
|
||||
"optionally set OMNIGENT_RUNNER_HOST_ID to use a specific host."
|
||||
)
|
||||
|
||||
host_id = self._runner_host_id or await self._select_random_online_host()
|
||||
self._logger.info(
|
||||
"Launching Omnigent runner session_id=%s host_id=%s workspace=%s",
|
||||
session_id,
|
||||
host_id,
|
||||
self._runner_workspace,
|
||||
)
|
||||
response = await self._client.post(
|
||||
f"/v1/hosts/{host_id}/runners",
|
||||
json={"session_id": session_id, "workspace": self._runner_workspace},
|
||||
)
|
||||
await _raise_for_status(response)
|
||||
payload = response.json()
|
||||
runner_id = _extract_runner_id(payload)
|
||||
if runner_id is None:
|
||||
raise OmnigentError(f"Launch runner response did not include a runner id: {payload!r}")
|
||||
|
||||
await self.wait_for_runner_online(runner_id)
|
||||
self._logger.info(
|
||||
"Launched Omnigent runner session_id=%s runner_id=%s host_id=%s",
|
||||
session_id,
|
||||
runner_id,
|
||||
host_id,
|
||||
)
|
||||
return runner_id
|
||||
|
||||
async def list_agents(self) -> list[dict[str, Any]]:
|
||||
self._logger.debug("Listing built-in Omnigent agents")
|
||||
response = await self._client.get("/v1/agents")
|
||||
await _raise_for_status(response)
|
||||
payload = response.json()
|
||||
data = _extract_list(payload, "data") or _extract_list(payload, "agents")
|
||||
if data is None:
|
||||
data = payload if isinstance(payload, list) else []
|
||||
agents = [item for item in data if isinstance(item, dict)]
|
||||
self._logger.info("Found built-in Omnigent agents count=%s", len(agents))
|
||||
return agents
|
||||
|
||||
async def list_runners(self) -> list[dict[str, Any]]:
|
||||
self._logger.debug("Listing online Omnigent runners")
|
||||
response = await self._client.get("/v1/runners")
|
||||
await _raise_for_status(response)
|
||||
payload = response.json()
|
||||
data = _extract_list(payload, "data") or _extract_list(payload, "runners")
|
||||
if data is None:
|
||||
data = payload if isinstance(payload, list) else []
|
||||
runners = [item for item in data if isinstance(item, dict)]
|
||||
self._logger.info("Found online Omnigent runners count=%s", len(runners))
|
||||
return runners
|
||||
|
||||
async def list_hosts(self) -> list[dict[str, Any]]:
|
||||
self._logger.debug("Listing Omnigent hosts")
|
||||
response = await self._client.get("/v1/hosts")
|
||||
await _raise_for_status(response)
|
||||
payload = response.json()
|
||||
data = _extract_list(payload, "hosts") or _extract_list(payload, "data")
|
||||
if data is None:
|
||||
data = payload if isinstance(payload, list) else []
|
||||
hosts = [item for item in data if isinstance(item, dict)]
|
||||
self._logger.info("Found Omnigent hosts count=%s", len(hosts))
|
||||
return hosts
|
||||
|
||||
async def list_runner_ids_from_hosts(self) -> list[str]:
|
||||
hosts = await self.list_hosts()
|
||||
runner_ids: list[str] = []
|
||||
for host in hosts:
|
||||
if not _is_host_online(host):
|
||||
continue
|
||||
runner_ids.extend(_runner_ids_from_host(host))
|
||||
return sorted(set(runner_ids))
|
||||
|
||||
async def wait_for_runner_online(self, runner_id: str) -> None:
|
||||
deadline = asyncio.get_running_loop().time() + self._runner_launch_timeout_seconds
|
||||
while True:
|
||||
response = await self._client.get(f"/v1/runners/{runner_id}/status")
|
||||
await _raise_for_status(response)
|
||||
payload = response.json()
|
||||
if isinstance(payload, dict) and payload.get("online") is True:
|
||||
return
|
||||
if asyncio.get_running_loop().time() >= deadline:
|
||||
raise OmnigentError(
|
||||
f"Timed out waiting for launched Omnigent runner to come online: {runner_id}"
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _select_random_online_host(self) -> str:
|
||||
hosts = await self.list_hosts()
|
||||
host_ids = [
|
||||
host_id
|
||||
for host in hosts
|
||||
if _is_host_online(host) and (host_id := _host_id(host)) is not None
|
||||
]
|
||||
if not host_ids:
|
||||
raise OmnigentError(
|
||||
"No online Omnigent hosts are available to launch a runner. "
|
||||
"Set OMNIGENT_RUNNER_HOST_ID to a specific online host, or start a host."
|
||||
)
|
||||
host_id = random.choice(host_ids)
|
||||
self._logger.info(
|
||||
"Selected random Omnigent host host_id=%s candidates=%s",
|
||||
host_id,
|
||||
len(host_ids),
|
||||
)
|
||||
return host_id
|
||||
|
||||
@asynccontextmanager
|
||||
async def stream_session_events(
|
||||
self,
|
||||
session_id: str,
|
||||
) -> AsyncIterator[AsyncIterator[dict[str, Any]]]:
|
||||
async with self._client.stream(
|
||||
"GET",
|
||||
f"/v1/sessions/{session_id}/stream",
|
||||
params={"idle": "false"},
|
||||
) as response:
|
||||
await _raise_for_status(response)
|
||||
self._logger.debug("Connected to Omnigent SSE stream session_id=%s", session_id)
|
||||
yield iter_sse_events(response.aiter_lines())
|
||||
|
||||
async def run_turn(self, session_id: str, text: str) -> AsyncIterator[dict[str, Any]]:
|
||||
try:
|
||||
async for event in self._run_turn_once(session_id, text):
|
||||
yield event
|
||||
return
|
||||
except RunnerUnavailableError:
|
||||
self._logger.info(
|
||||
"Session has no available runner; "
|
||||
"binding a random runner and retrying session_id=%s",
|
||||
session_id,
|
||||
)
|
||||
await self.bind_random_runner(session_id)
|
||||
|
||||
async for event in self._run_turn_once(session_id, text):
|
||||
yield event
|
||||
|
||||
async def _run_turn_once(self, session_id: str, text: str) -> AsyncIterator[dict[str, Any]]:
|
||||
async with self.stream_session_events(session_id) as events:
|
||||
await self.submit_message(session_id, text)
|
||||
async for event in events:
|
||||
self._logger.debug(
|
||||
"Received Omnigent event session_id=%s type=%s",
|
||||
session_id,
|
||||
event.get("type"),
|
||||
)
|
||||
yield event
|
||||
if is_terminal_event(event):
|
||||
self._logger.info(
|
||||
"Omnigent turn reached terminal event session_id=%s type=%s",
|
||||
session_id,
|
||||
event.get("type"),
|
||||
)
|
||||
break
|
||||
|
||||
async def latest_assistant_text(self, session_id: str) -> str | None:
|
||||
self._logger.debug("Fetching latest Omnigent assistant item session_id=%s", session_id)
|
||||
response = await self._client.get(
|
||||
f"/v1/sessions/{session_id}/items",
|
||||
params={"limit": 100, "order": "desc"},
|
||||
)
|
||||
await _raise_for_status(response)
|
||||
payload = response.json()
|
||||
items = payload.get("data", [])
|
||||
if not isinstance(items, list):
|
||||
return None
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
text = extract_assistant_text(item)
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
async def iter_sse_events(lines: AsyncIterator[str]) -> AsyncIterator[dict[str, Any]]:
|
||||
event_name: str | None = None
|
||||
data_lines: list[str] = []
|
||||
|
||||
async for raw_line in lines:
|
||||
line = raw_line.rstrip("\r")
|
||||
if line == "":
|
||||
event = _decode_sse_event(event_name, data_lines)
|
||||
event_name = None
|
||||
data_lines = []
|
||||
if event is None:
|
||||
continue
|
||||
if event == "[DONE]":
|
||||
break
|
||||
if isinstance(event, str):
|
||||
continue
|
||||
yield event
|
||||
continue
|
||||
|
||||
if line.startswith(":"):
|
||||
continue
|
||||
|
||||
field, separator, value = line.partition(":")
|
||||
if separator and value.startswith(" "):
|
||||
value = value[1:]
|
||||
if field == "event":
|
||||
event_name = value
|
||||
elif field == "data":
|
||||
data_lines.append(value)
|
||||
|
||||
event = _decode_sse_event(event_name, data_lines)
|
||||
if isinstance(event, dict):
|
||||
yield event
|
||||
|
||||
|
||||
def is_terminal_event(event: dict[str, Any]) -> bool:
|
||||
# A turn ends at the SESSION level, not the response level. Orchestrator
|
||||
# agents emit a `response.completed`/`turn.completed` every time they end a
|
||||
# turn to wait on a background sub-agent, then resume with more responses in
|
||||
# the same turn — so treating those as terminal cuts the stream off at the
|
||||
# first sub-agent dispatch. `session.status` is the authoritative signal:
|
||||
# `running` -> `waiting` (parked on async work) -> `running` -> `idle`, and
|
||||
# only `idle`/`failed` mean the turn is truly over.
|
||||
event_type = str(event.get("type"))
|
||||
if event_type == "session.status":
|
||||
return str(event.get("status")) in {"idle", "failed"}
|
||||
# Explicit turn/response failure and cancellation still end the turn; keep
|
||||
# them as a fallback in case the session settles without an `idle` edge.
|
||||
return event_type in {
|
||||
"response.failed",
|
||||
"response.cancelled",
|
||||
"turn.failed",
|
||||
"turn.cancelled",
|
||||
}
|
||||
|
||||
|
||||
def extract_delta(event: dict[str, Any]) -> str | None:
|
||||
if event.get("type") != "response.output_text.delta":
|
||||
return None
|
||||
delta = event.get("delta")
|
||||
return delta if isinstance(delta, str) else None
|
||||
|
||||
|
||||
def extract_error_text(event: dict[str, Any]) -> str | None:
|
||||
event_type = str(event.get("type"))
|
||||
if event_type == "response.error":
|
||||
error = event.get("error")
|
||||
if isinstance(error, dict):
|
||||
message = error.get("message")
|
||||
if isinstance(message, str):
|
||||
return message
|
||||
message = event.get("message")
|
||||
if isinstance(message, str):
|
||||
return message
|
||||
if event_type in {"response.failed", "turn.failed"}:
|
||||
response = event.get("response")
|
||||
if isinstance(response, dict):
|
||||
last_error = response.get("error") or response.get("last_error")
|
||||
if isinstance(last_error, dict):
|
||||
message = last_error.get("message")
|
||||
if isinstance(message, str):
|
||||
return message
|
||||
error = event.get("error")
|
||||
if isinstance(error, dict):
|
||||
message = error.get("message")
|
||||
if isinstance(message, str):
|
||||
return message
|
||||
if isinstance(error, str):
|
||||
return error
|
||||
return None
|
||||
|
||||
|
||||
def extract_assistant_text(event_or_item: dict[str, Any]) -> str | None:
|
||||
if event_or_item.get("type") == "response.output_item.done":
|
||||
item = event_or_item.get("item")
|
||||
return extract_assistant_text(item) if isinstance(item, dict) else None
|
||||
|
||||
item_type = event_or_item.get("type")
|
||||
if item_type != "message":
|
||||
return None
|
||||
|
||||
data = event_or_item.get("data")
|
||||
message = data if isinstance(data, dict) else event_or_item
|
||||
if message.get("role") != "assistant":
|
||||
return None
|
||||
|
||||
content = message.get("content")
|
||||
if not isinstance(content, list):
|
||||
return None
|
||||
|
||||
parts: list[str] = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
text = block.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "".join(parts).strip() or None
|
||||
|
||||
|
||||
def _decode_sse_event(event_name: str | None, data_lines: list[str]) -> dict[str, Any] | str | None:
|
||||
if not data_lines:
|
||||
return None
|
||||
data = "\n".join(data_lines)
|
||||
if data == "[DONE]":
|
||||
return data
|
||||
try:
|
||||
payload = json.loads(data)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise OmnigentError(f"Invalid SSE JSON payload: {data}") from exc
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
if event_name and "type" not in payload:
|
||||
payload["type"] = event_name
|
||||
return payload
|
||||
|
||||
|
||||
def _extract_session_id(payload: Any) -> str | None:
|
||||
if isinstance(payload, dict):
|
||||
for key in ("id", "session_id", "conversation_id"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
for key in ("session", "data"):
|
||||
value = _extract_session_id(payload.get(key))
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _extract_list(payload: Any, key: str) -> list[Any] | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
value = payload.get(key)
|
||||
return value if isinstance(value, list) else None
|
||||
|
||||
|
||||
def _runner_id(runner: dict[str, Any]) -> str | None:
|
||||
for key in ("id", "runner_id"):
|
||||
value = runner.get(key)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _runner_ids_from_host(host: dict[str, Any]) -> list[str]:
|
||||
runner_ids: list[str] = []
|
||||
|
||||
for key in (
|
||||
"runner_id",
|
||||
"active_runner_id",
|
||||
"current_runner_id",
|
||||
):
|
||||
value = host.get(key)
|
||||
if isinstance(value, str):
|
||||
runner_ids.append(value)
|
||||
|
||||
for key in (
|
||||
"runner_ids",
|
||||
"active_runner_ids",
|
||||
"current_runner_ids",
|
||||
"live_runner_ids",
|
||||
"online_runner_ids",
|
||||
):
|
||||
value = host.get(key)
|
||||
if isinstance(value, list):
|
||||
runner_ids.extend(item for item in value if isinstance(item, str))
|
||||
|
||||
for key in ("runner", "active_runner", "current_runner"):
|
||||
value = host.get(key)
|
||||
if isinstance(value, str):
|
||||
runner_ids.append(value)
|
||||
elif isinstance(value, dict):
|
||||
runner_id = _runner_id(value)
|
||||
if runner_id:
|
||||
runner_ids.append(runner_id)
|
||||
|
||||
for key in (
|
||||
"runners",
|
||||
"active_runners",
|
||||
"current_runners",
|
||||
"live_runners",
|
||||
"online_runners",
|
||||
):
|
||||
value = host.get(key)
|
||||
if not isinstance(value, list):
|
||||
continue
|
||||
for item in value:
|
||||
if isinstance(item, str):
|
||||
runner_ids.append(item)
|
||||
elif isinstance(item, dict):
|
||||
runner_id = _runner_id(item)
|
||||
if runner_id:
|
||||
runner_ids.append(runner_id)
|
||||
|
||||
return runner_ids
|
||||
|
||||
|
||||
def _extract_runner_id(payload: Any) -> str | None:
|
||||
if isinstance(payload, dict):
|
||||
value = _runner_id(payload)
|
||||
if value:
|
||||
return value
|
||||
for key in ("runner", "data"):
|
||||
value = _extract_runner_id(payload.get(key))
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _host_id(host: dict[str, Any]) -> str | None:
|
||||
for key in ("id", "host_id"):
|
||||
value = host.get(key)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _is_host_online(host: dict[str, Any]) -> bool:
|
||||
if host.get("online") is True or host.get("host_online") is True:
|
||||
return True
|
||||
status = host.get("status")
|
||||
return isinstance(status, str) and status.lower() == "online"
|
||||
|
||||
|
||||
async def _raise_for_status(response: httpx.Response) -> None:
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
error_code = _extract_error_code(response)
|
||||
if response.status_code == 503 and error_code == "runner_unavailable":
|
||||
raise RunnerUnavailableError(
|
||||
f"Omnigent runner unavailable for {response.request.url}: {response.text}"
|
||||
) from exc
|
||||
raise OmnigentError(
|
||||
f"Omnigent request failed with {response.status_code}: {response.text}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _extract_error_code(response: httpx.Response) -> str | None:
|
||||
try:
|
||||
payload = response.json()
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
error = payload.get("error")
|
||||
if not isinstance(error, dict):
|
||||
return None
|
||||
code = error.get("code")
|
||||
return code if isinstance(code, str) else None
|
||||
@@ -1,391 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Protocol
|
||||
|
||||
from omnigent_slack.dispatcher import ThreadTurnDispatcher
|
||||
from omnigent_slack.models import SlackTurn, ThreadKey
|
||||
from omnigent_slack.omnigent import (
|
||||
OmnigentClient,
|
||||
extract_assistant_text,
|
||||
extract_delta,
|
||||
extract_error_text,
|
||||
)
|
||||
from omnigent_slack.store import SQLiteStore
|
||||
from omnigent_slack.text import (
|
||||
normalize_whitespace,
|
||||
split_for_slack,
|
||||
strip_bot_mention,
|
||||
to_mrkdwn,
|
||||
truncate_for_slack,
|
||||
)
|
||||
|
||||
|
||||
class SlackClientProtocol(Protocol):
|
||||
async def chat_postMessage(self, **kwargs: Any) -> dict[str, Any]: ...
|
||||
|
||||
async def chat_update(self, **kwargs: Any) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class SlackOmnigentService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
store: SQLiteStore,
|
||||
omnigent: OmnigentClient,
|
||||
omnigent_agent_id: str,
|
||||
update_interval_seconds: float = 1.0,
|
||||
bot_user_id: str | None = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._omnigent = omnigent
|
||||
self._omnigent_agent_id = omnigent_agent_id
|
||||
self._update_interval_seconds = update_interval_seconds
|
||||
self._bot_user_id = bot_user_id
|
||||
self._dispatcher = ThreadTurnDispatcher(self._run_turn)
|
||||
self._logger = logging.getLogger(__name__)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
await self._dispatcher.shutdown()
|
||||
|
||||
async def handle_app_mention(
|
||||
self,
|
||||
*,
|
||||
body: dict[str, Any],
|
||||
event: dict[str, Any],
|
||||
client: SlackClientProtocol,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self._logger.info(
|
||||
"Received Slack app_mention team=%s channel=%s ts=%s user=%s event_id=%s",
|
||||
body.get("team_id") or event.get("team"),
|
||||
event.get("channel"),
|
||||
event.get("ts"),
|
||||
event.get("user"),
|
||||
body.get("event_id") or event.get("client_msg_id"),
|
||||
)
|
||||
if not await self._claim_event(body, event):
|
||||
self._logger.info(
|
||||
"Ignoring duplicate Slack app_mention event_id=%s",
|
||||
body.get("event_id") or event.get("client_msg_id"),
|
||||
)
|
||||
return
|
||||
bot_user_id = self._resolve_bot_user_id(context)
|
||||
if self._should_ignore_message(event, bot_user_id):
|
||||
self._logger.info(
|
||||
"Ignoring Slack app_mention subtype=%s bot_id=%s user=%s bot_user_id=%s",
|
||||
event.get("subtype"),
|
||||
event.get("bot_id"),
|
||||
event.get("user"),
|
||||
bot_user_id,
|
||||
)
|
||||
return
|
||||
|
||||
team_id = _team_id(body, event)
|
||||
key = ThreadKey.from_event(team_id, event)
|
||||
text = strip_bot_mention(str(event.get("text") or ""), bot_user_id)
|
||||
if not text:
|
||||
self._logger.info(
|
||||
"Slack app_mention had no text after mention thread=%s",
|
||||
key.display(),
|
||||
)
|
||||
await client.chat_postMessage(
|
||||
channel=key.channel_id,
|
||||
thread_ts=key.thread_ts,
|
||||
text="Send a message after mentioning me to start a session.",
|
||||
)
|
||||
return
|
||||
|
||||
self._logger.info("Accepted Slack app_mention thread=%s chars=%s", key.display(), len(text))
|
||||
await self._dispatcher.enqueue(
|
||||
SlackTurn(
|
||||
key=key,
|
||||
text=text,
|
||||
user_id=str(event.get("user") or ""),
|
||||
create_if_missing=True,
|
||||
title=_session_title(event, text),
|
||||
slack_client=client,
|
||||
)
|
||||
)
|
||||
|
||||
async def handle_message(
|
||||
self,
|
||||
*,
|
||||
body: dict[str, Any],
|
||||
event: dict[str, Any],
|
||||
client: SlackClientProtocol,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self._logger.info(
|
||||
"Received Slack message team=%s channel=%s ts=%s thread_ts=%s user=%s event_id=%s",
|
||||
body.get("team_id") or event.get("team"),
|
||||
event.get("channel"),
|
||||
event.get("ts"),
|
||||
event.get("thread_ts"),
|
||||
event.get("user"),
|
||||
body.get("event_id") or event.get("client_msg_id"),
|
||||
)
|
||||
if not await self._claim_event(body, event):
|
||||
self._logger.info(
|
||||
"Ignoring duplicate Slack message event_id=%s",
|
||||
body.get("event_id") or event.get("client_msg_id"),
|
||||
)
|
||||
return
|
||||
bot_user_id = self._resolve_bot_user_id(context)
|
||||
if self._should_ignore_message(event, bot_user_id):
|
||||
self._logger.info(
|
||||
"Ignoring Slack message subtype=%s bot_id=%s user=%s bot_user_id=%s",
|
||||
event.get("subtype"),
|
||||
event.get("bot_id"),
|
||||
event.get("user"),
|
||||
bot_user_id,
|
||||
)
|
||||
return
|
||||
|
||||
raw_text = str(event.get("text") or "")
|
||||
if bot_user_id and f"<@{bot_user_id}" in raw_text:
|
||||
self._logger.info("Ignoring generic message containing bot mention")
|
||||
return
|
||||
|
||||
team_id = _team_id(body, event)
|
||||
key = ThreadKey.from_event(team_id, event)
|
||||
if await self._store.get_session_id(key) is None:
|
||||
self._logger.info(
|
||||
"Ignoring Slack message with no Omnigent session thread=%s",
|
||||
key.display(),
|
||||
)
|
||||
return
|
||||
|
||||
text = normalize_whitespace(raw_text)
|
||||
if not text:
|
||||
self._logger.info("Ignoring empty Slack message thread=%s", key.display())
|
||||
return
|
||||
|
||||
self._logger.info(
|
||||
"Accepted Slack thread reply thread=%s chars=%s",
|
||||
key.display(),
|
||||
len(text),
|
||||
)
|
||||
await self._dispatcher.enqueue(
|
||||
SlackTurn(
|
||||
key=key,
|
||||
text=text,
|
||||
user_id=str(event.get("user") or ""),
|
||||
create_if_missing=False,
|
||||
title=_session_title(event, text),
|
||||
slack_client=client,
|
||||
)
|
||||
)
|
||||
|
||||
async def _run_turn(self, turn: SlackTurn) -> None:
|
||||
self._logger.info("Starting turn thread=%s chars=%s", turn.key.display(), len(turn.text))
|
||||
session_id = await self._store.get_session_id(turn.key)
|
||||
if session_id is None:
|
||||
if not turn.create_if_missing:
|
||||
self._logger.info(
|
||||
"No session found and creation disabled thread=%s",
|
||||
turn.key.display(),
|
||||
)
|
||||
return
|
||||
session_id = await self._omnigent.create_session(self._omnigent_agent_id, turn.title)
|
||||
runner_id = await self._omnigent.bind_random_runner(session_id)
|
||||
await self._store.upsert_session(turn.key, session_id, turn.title)
|
||||
self._logger.info(
|
||||
"Mapped Slack thread to new Omnigent session thread=%s session_id=%s runner_id=%s",
|
||||
turn.key.display(),
|
||||
session_id,
|
||||
runner_id,
|
||||
)
|
||||
else:
|
||||
self._logger.info(
|
||||
"Using existing Omnigent session thread=%s session_id=%s",
|
||||
turn.key.display(),
|
||||
session_id,
|
||||
)
|
||||
|
||||
slack_client = turn.slack_client
|
||||
|
||||
placeholder = await slack_client.chat_postMessage(
|
||||
channel=turn.key.channel_id,
|
||||
thread_ts=turn.key.thread_ts,
|
||||
text="Working...",
|
||||
)
|
||||
message_ts = str(placeholder.get("ts") or "")
|
||||
if not message_ts:
|
||||
self._logger.error("Slack placeholder response missing ts: %r", placeholder)
|
||||
return
|
||||
self._logger.info(
|
||||
"Posted Slack placeholder thread=%s message_ts=%s",
|
||||
turn.key.display(),
|
||||
message_ts,
|
||||
)
|
||||
|
||||
streamed_text = ""
|
||||
final_text: str | None = None
|
||||
error_text: str | None = None
|
||||
last_update = 0.0
|
||||
|
||||
try:
|
||||
async for omnigent_event in self._omnigent.run_turn(session_id, turn.text):
|
||||
delta = extract_delta(omnigent_event)
|
||||
if delta:
|
||||
streamed_text += delta
|
||||
self._logger.debug(
|
||||
"Accumulated Omnigent delta thread=%s total_chars=%s",
|
||||
turn.key.display(),
|
||||
len(streamed_text),
|
||||
)
|
||||
now = time.monotonic()
|
||||
if now - last_update >= self._update_interval_seconds:
|
||||
# A progress edit is best-effort: a failure here (e.g. a
|
||||
# transient Slack error) must not abort the turn or
|
||||
# clobber the real answer delivered below.
|
||||
try:
|
||||
await self._update_slack(
|
||||
slack_client, turn.key, message_ts, streamed_text
|
||||
)
|
||||
except Exception:
|
||||
self._logger.warning(
|
||||
"Slack progress update failed thread=%s; continuing",
|
||||
turn.key.display(),
|
||||
exc_info=True,
|
||||
)
|
||||
last_update = now
|
||||
|
||||
item_text = extract_assistant_text(omnigent_event)
|
||||
if item_text:
|
||||
final_text = item_text
|
||||
|
||||
event_error = extract_error_text(omnigent_event)
|
||||
if event_error:
|
||||
error_text = event_error
|
||||
except Exception as exc:
|
||||
self._logger.exception("Omnigent turn failed for %s", turn.key.display())
|
||||
error_text = str(exc)
|
||||
|
||||
# Resolve the answer independently of any error so a failure never
|
||||
# erases what the user already saw stream in.
|
||||
if not final_text:
|
||||
final_text = streamed_text.strip() or await self._omnigent.latest_assistant_text(
|
||||
session_id
|
||||
)
|
||||
|
||||
if final_text:
|
||||
# Deliver the real answer, then, if the turn also errored, report
|
||||
# the failure as a separate reply instead of overwriting it.
|
||||
await self._deliver_final(slack_client, turn.key, message_ts, final_text)
|
||||
if error_text:
|
||||
await self._post_failure_reply(slack_client, turn.key, error_text)
|
||||
else:
|
||||
# Nothing to preserve — surface the error (or a fallback) in the
|
||||
# placeholder itself.
|
||||
fallback = (
|
||||
f"Omnigent request failed: {error_text}"
|
||||
if error_text
|
||||
else "Omnigent completed without returning response text."
|
||||
)
|
||||
await self._deliver_final(slack_client, turn.key, message_ts, fallback)
|
||||
|
||||
self._logger.info(
|
||||
"Completed Slack turn thread=%s session_id=%s final_chars=%s errored=%s",
|
||||
turn.key.display(),
|
||||
session_id,
|
||||
len(final_text or ""),
|
||||
bool(error_text),
|
||||
)
|
||||
|
||||
async def _post_failure_reply(
|
||||
self,
|
||||
client: SlackClientProtocol,
|
||||
key: ThreadKey,
|
||||
error_text: str,
|
||||
) -> None:
|
||||
# Post the failure as its own thread reply so the already-delivered
|
||||
# answer stays intact. Keep it to a single message.
|
||||
await client.chat_postMessage(
|
||||
channel=key.channel_id,
|
||||
thread_ts=key.thread_ts,
|
||||
text=truncate_for_slack(f":warning: Omnigent request failed: {error_text}"),
|
||||
)
|
||||
|
||||
async def _deliver_final(
|
||||
self,
|
||||
client: SlackClientProtocol,
|
||||
key: ThreadKey,
|
||||
message_ts: str,
|
||||
text: str,
|
||||
) -> None:
|
||||
# The server returns standard Markdown; convert it to Slack's mrkdwn
|
||||
# dialect before display. A single Slack message can't hold a long
|
||||
# answer, so split the converted text and edit the placeholder to the
|
||||
# first chunk, posting the rest as thread replies — delivering the full
|
||||
# answer instead of truncating it.
|
||||
chunks = split_for_slack(to_mrkdwn(text))
|
||||
await self._update_slack(client, key, message_ts, chunks[0])
|
||||
for chunk in chunks[1:]:
|
||||
await client.chat_postMessage(
|
||||
channel=key.channel_id,
|
||||
thread_ts=key.thread_ts,
|
||||
text=chunk,
|
||||
)
|
||||
if len(chunks) > 1:
|
||||
self._logger.info(
|
||||
"Delivered long Slack answer across parts thread=%s parts=%s",
|
||||
key.display(),
|
||||
len(chunks),
|
||||
)
|
||||
|
||||
async def _update_slack(
|
||||
self,
|
||||
client: SlackClientProtocol,
|
||||
key: ThreadKey,
|
||||
message_ts: str,
|
||||
text: str,
|
||||
) -> None:
|
||||
self._logger.debug(
|
||||
"Updating Slack message thread=%s message_ts=%s chars=%s",
|
||||
key.display(),
|
||||
message_ts,
|
||||
len(text),
|
||||
)
|
||||
await client.chat_update(
|
||||
channel=key.channel_id,
|
||||
ts=message_ts,
|
||||
text=truncate_for_slack(text),
|
||||
)
|
||||
|
||||
async def _claim_event(self, body: dict[str, Any], event: dict[str, Any]) -> bool:
|
||||
event_id = body.get("event_id") or event.get("client_msg_id")
|
||||
return await self._store.claim_event(str(event_id) if event_id else None)
|
||||
|
||||
def _resolve_bot_user_id(self, context: dict[str, Any] | None) -> str | None:
|
||||
bot_user_id = None if context is None else context.get("bot_user_id")
|
||||
if isinstance(bot_user_id, str):
|
||||
self._bot_user_id = bot_user_id
|
||||
return bot_user_id
|
||||
return self._bot_user_id
|
||||
|
||||
@staticmethod
|
||||
def _should_ignore_message(event: dict[str, Any], bot_user_id: str | None) -> bool:
|
||||
subtype = event.get("subtype")
|
||||
if subtype in {"bot_message", "message_changed", "message_deleted"}:
|
||||
return True
|
||||
if event.get("bot_id"):
|
||||
return True
|
||||
user_id = event.get("user")
|
||||
return bool(bot_user_id and user_id == bot_user_id)
|
||||
|
||||
|
||||
def _team_id(body: dict[str, Any], event: dict[str, Any]) -> str:
|
||||
team_id = body.get("team_id") or event.get("team")
|
||||
if not team_id:
|
||||
raise ValueError("Slack event is missing team_id")
|
||||
return str(team_id)
|
||||
|
||||
|
||||
def _session_title(event: dict[str, Any], text: str) -> str:
|
||||
channel = str(event.get("channel") or "channel")
|
||||
thread_ts = str(event.get("thread_ts") or event.get("ts") or "thread")
|
||||
summary = truncate_for_slack(text, limit=80).replace("\n", " ")
|
||||
return f"Slack {channel}/{thread_ts}: {summary}"
|
||||
@@ -1,92 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from omnigent_slack.models import ThreadKey
|
||||
|
||||
|
||||
class SQLiteStore:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self._path = path
|
||||
|
||||
async def initialize(self) -> None:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
async with aiosqlite.connect(self._path) as db:
|
||||
await db.execute("PRAGMA journal_mode=WAL")
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS thread_sessions (
|
||||
team_id TEXT NOT NULL,
|
||||
channel_id TEXT NOT NULL,
|
||||
thread_ts TEXT NOT NULL,
|
||||
omnigent_session_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (team_id, channel_id, thread_ts)
|
||||
)
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS slack_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def get_session_id(self, key: ThreadKey) -> str | None:
|
||||
async with aiosqlite.connect(self._path) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
SELECT omnigent_session_id
|
||||
FROM thread_sessions
|
||||
WHERE team_id = ? AND channel_id = ? AND thread_ts = ?
|
||||
""",
|
||||
(key.team_id, key.channel_id, key.thread_ts),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
await cursor.close()
|
||||
if row is None:
|
||||
return None
|
||||
return str(row[0])
|
||||
|
||||
async def upsert_session(self, key: ThreadKey, session_id: str, title: str) -> None:
|
||||
now = int(time.time())
|
||||
async with aiosqlite.connect(self._path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO thread_sessions (
|
||||
team_id, channel_id, thread_ts, omnigent_session_id,
|
||||
title, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(team_id, channel_id, thread_ts) DO UPDATE SET
|
||||
omnigent_session_id = excluded.omnigent_session_id,
|
||||
title = excluded.title,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(key.team_id, key.channel_id, key.thread_ts, session_id, title, now, now),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def claim_event(self, event_id: str | None, ttl_seconds: int = 7 * 24 * 60 * 60) -> bool:
|
||||
if not event_id:
|
||||
return True
|
||||
|
||||
now = int(time.time())
|
||||
async with aiosqlite.connect(self._path) as db:
|
||||
cursor = await db.execute(
|
||||
"INSERT OR IGNORE INTO slack_events (event_id, created_at) VALUES (?, ?)",
|
||||
(event_id, now),
|
||||
)
|
||||
claimed = cursor.rowcount == 1
|
||||
await cursor.close()
|
||||
await db.execute("DELETE FROM slack_events WHERE created_at < ?", (now - ttl_seconds,))
|
||||
await db.commit()
|
||||
return claimed
|
||||
@@ -1,79 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from markdown_to_mrkdwn import SlackMarkdownConverter
|
||||
|
||||
MENTION_RE = re.compile(r"<@([A-Z0-9]+)(?:\|[^>]+)?>")
|
||||
WHITESPACE_RE = re.compile(r"\s+")
|
||||
|
||||
# Slack renders its own `mrkdwn` dialect, not standard Markdown (e.g. *bold* is
|
||||
# single-asterisk, links are <url|text>). Reuse one converter instance — it
|
||||
# compiles regex patterns on init.
|
||||
_MRKDWN_CONVERTER = SlackMarkdownConverter()
|
||||
|
||||
|
||||
def to_mrkdwn(text: str) -> str:
|
||||
"""Convert standard Markdown to Slack's mrkdwn dialect for display."""
|
||||
return str(_MRKDWN_CONVERTER.convert(text))
|
||||
|
||||
|
||||
def strip_bot_mention(text: str, bot_user_id: str | None) -> str:
|
||||
if bot_user_id:
|
||||
text = re.sub(rf"<@{re.escape(bot_user_id)}(?:\|[^>]+)?>", " ", text)
|
||||
else:
|
||||
text = MENTION_RE.sub(" ", text, count=1)
|
||||
return normalize_whitespace(text)
|
||||
|
||||
|
||||
def normalize_whitespace(text: str) -> str:
|
||||
return WHITESPACE_RE.sub(" ", text).strip()
|
||||
|
||||
|
||||
# Slack accepts up to 40,000 characters in a message `text`, but its own
|
||||
# guidance is to keep messages under 4,000 so they render without a "Show more"
|
||||
# fold. Stay at that best-practice ceiling and split longer answers across
|
||||
# replies (see `split_for_slack`).
|
||||
SLACK_MESSAGE_CHAR_LIMIT = 4000
|
||||
|
||||
|
||||
def truncate_for_slack(text: str, limit: int = SLACK_MESSAGE_CHAR_LIMIT) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
suffix = "\n\n[truncated]"
|
||||
if limit <= len(suffix):
|
||||
return text[:limit]
|
||||
return text[: limit - len(suffix)].rstrip() + suffix
|
||||
|
||||
|
||||
def split_for_slack(text: str, limit: int = SLACK_MESSAGE_CHAR_LIMIT) -> list[str]:
|
||||
"""Split ``text`` into chunks no longer than ``limit`` characters.
|
||||
|
||||
Preserves every character so a long assistant answer (code blocks,
|
||||
reports) is delivered in full across multiple messages instead of being
|
||||
truncated. Prefers to break after a newline, then a space, and only
|
||||
hard-cuts a run with no whitespace (e.g. a long URL). A blank string
|
||||
yields ``[""]`` so the caller always has a message to post.
|
||||
"""
|
||||
if limit <= 0:
|
||||
raise ValueError("limit must be positive")
|
||||
if not text:
|
||||
return [""]
|
||||
|
||||
chunks: list[str] = []
|
||||
start = 0
|
||||
length = len(text)
|
||||
while start < length:
|
||||
end = start + limit
|
||||
if end >= length:
|
||||
chunks.append(text[start:])
|
||||
break
|
||||
window = text[start:end]
|
||||
boundary = window.rfind("\n")
|
||||
if boundary == -1:
|
||||
boundary = window.rfind(" ")
|
||||
# Include the delimiter in the current chunk; hard-cut if none found.
|
||||
cut = end if boundary <= 0 else start + boundary + 1
|
||||
chunks.append(text[start:cut])
|
||||
start = cut
|
||||
return chunks
|
||||
@@ -1,97 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
from omnigent_slack.dispatcher import ThreadTurnDispatcher
|
||||
from omnigent_slack.models import SlackTurn, ThreadKey
|
||||
|
||||
|
||||
def _turn(key: ThreadKey, text: str) -> SlackTurn:
|
||||
return SlackTurn(
|
||||
key=key,
|
||||
text=text,
|
||||
user_id="U",
|
||||
create_if_missing=False,
|
||||
title="title",
|
||||
slack_client=object(),
|
||||
)
|
||||
|
||||
|
||||
async def test_dispatcher_runs_turns_in_thread_order() -> None:
|
||||
seen: list[str] = []
|
||||
done = asyncio.Event()
|
||||
|
||||
async def worker(turn: SlackTurn) -> None:
|
||||
await asyncio.sleep(0)
|
||||
seen.append(turn.text)
|
||||
if len(seen) == 3:
|
||||
done.set()
|
||||
|
||||
dispatcher = ThreadTurnDispatcher(worker, idle_timeout_seconds=0.1)
|
||||
key = ThreadKey(team_id="T", channel_id="C", thread_ts="1")
|
||||
|
||||
for text in ["one", "two", "three"]:
|
||||
await dispatcher.enqueue(_turn(key, text))
|
||||
|
||||
await asyncio.wait_for(done.wait(), timeout=1)
|
||||
await dispatcher.shutdown()
|
||||
|
||||
assert seen == ["one", "two", "three"]
|
||||
|
||||
|
||||
async def test_enqueue_during_idle_teardown_is_not_wedged() -> None:
|
||||
"""A turn that arrives while an idle worker is tearing down must still run.
|
||||
|
||||
Reproduces the race where ``_run_queue`` times out on an empty queue and
|
||||
decides to exit, then ``enqueue`` slips a turn in before the teardown
|
||||
``finally`` reacquires the lock. The queue stays registered, so no new
|
||||
worker is ever spawned and the turn is stranded.
|
||||
"""
|
||||
seen: list[str] = []
|
||||
processed = asyncio.Event()
|
||||
|
||||
async def worker(turn: SlackTurn) -> None:
|
||||
seen.append(turn.text)
|
||||
processed.set()
|
||||
|
||||
dispatcher = ThreadTurnDispatcher(worker, idle_timeout_seconds=0.05)
|
||||
key = ThreadKey(team_id="T", channel_id="C", thread_ts="1")
|
||||
|
||||
# Gate the teardown's lock acquisition so a concurrent enqueue wins the
|
||||
# race: the worker has decided to exit but has not yet run its finally.
|
||||
original_lock = dispatcher._lock
|
||||
teardown_reached = asyncio.Event()
|
||||
release_teardown = asyncio.Event()
|
||||
|
||||
class _GatedLock:
|
||||
def __init__(self) -> None:
|
||||
self._enter_count = 0
|
||||
|
||||
async def __aenter__(self) -> None:
|
||||
self._enter_count += 1
|
||||
# The first acquisition after startup is enqueue's; the teardown
|
||||
# acquisition is the one we stall so enqueue can slip ahead.
|
||||
if self._enter_count == 2:
|
||||
teardown_reached.set()
|
||||
await release_teardown.wait()
|
||||
await original_lock.acquire()
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
original_lock.release()
|
||||
|
||||
dispatcher._lock = _GatedLock() # type: ignore[assignment]
|
||||
|
||||
# Let the worker spawn and hit its idle timeout → enter teardown.
|
||||
await dispatcher.enqueue(_turn(key, "first"))
|
||||
await asyncio.wait_for(processed.wait(), timeout=1)
|
||||
processed.clear()
|
||||
await asyncio.wait_for(teardown_reached.wait(), timeout=1)
|
||||
|
||||
# Enqueue arrives before teardown finishes. Restore the real lock so the
|
||||
# new enqueue path (and any re-armed worker) runs unhindered.
|
||||
dispatcher._lock = original_lock # type: ignore[assignment]
|
||||
await dispatcher.enqueue(_turn(key, "second"))
|
||||
release_teardown.set()
|
||||
|
||||
await asyncio.wait_for(processed.wait(), timeout=1)
|
||||
await dispatcher.shutdown()
|
||||
|
||||
assert seen == ["first", "second"]
|
||||
@@ -1,295 +0,0 @@
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import httpx
|
||||
import respx
|
||||
|
||||
from omnigent_slack.omnigent import (
|
||||
OmnigentAuth,
|
||||
OmnigentClient,
|
||||
OmnigentError,
|
||||
RunnerUnavailableError,
|
||||
extract_assistant_text,
|
||||
is_terminal_event,
|
||||
iter_sse_events,
|
||||
)
|
||||
|
||||
|
||||
def test_is_terminal_event_only_ends_on_session_idle_or_failed() -> None:
|
||||
# Per-response completions are NOT terminal: an orchestrator emits one each
|
||||
# time it ends a turn to wait on a sub-agent, then resumes the same turn.
|
||||
assert not is_terminal_event({"type": "response.completed"})
|
||||
assert not is_terminal_event({"type": "turn.completed"})
|
||||
assert not is_terminal_event({"type": "response.output_text.delta", "delta": "x"})
|
||||
assert not is_terminal_event({"type": "session.status", "status": "running"})
|
||||
assert not is_terminal_event({"type": "session.status", "status": "waiting"})
|
||||
|
||||
# The session settling is the authoritative turn boundary.
|
||||
assert is_terminal_event({"type": "session.status", "status": "idle"})
|
||||
assert is_terminal_event({"type": "session.status", "status": "failed"})
|
||||
|
||||
# Explicit failure/cancel still ends the turn as a fallback.
|
||||
assert is_terminal_event({"type": "response.failed"})
|
||||
assert is_terminal_event({"type": "turn.cancelled"})
|
||||
|
||||
|
||||
async def _lines(values: list[str]) -> AsyncIterator[str]:
|
||||
for value in values:
|
||||
yield value
|
||||
|
||||
|
||||
async def test_iter_sse_events_parses_json_and_done() -> None:
|
||||
events = [
|
||||
event
|
||||
async for event in iter_sse_events(
|
||||
_lines(
|
||||
[
|
||||
"event: response.output_text.delta",
|
||||
'data: {"delta":"hel"}',
|
||||
"",
|
||||
'data: {"type":"response.output_text.delta","delta":"lo"}',
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
]
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
assert events == [
|
||||
{"type": "response.output_text.delta", "delta": "hel"},
|
||||
{"type": "response.output_text.delta", "delta": "lo"},
|
||||
]
|
||||
|
||||
|
||||
def test_extract_assistant_text_from_stream_item() -> None:
|
||||
assert (
|
||||
extract_assistant_text(
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "done"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
== "done"
|
||||
)
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_client_create_and_submit_request_shapes() -> None:
|
||||
create = respx.post("http://omnigent.test/v1/sessions").mock(
|
||||
return_value=httpx.Response(201, json={"id": "conv_1"})
|
||||
)
|
||||
submit = respx.post("http://omnigent.test/v1/sessions/conv_1/events").mock(
|
||||
return_value=httpx.Response(200, json={})
|
||||
)
|
||||
client = OmnigentClient(
|
||||
"http://omnigent.test",
|
||||
auth=OmnigentAuth(email="bot@example.com", session_cookie="cookie-value"),
|
||||
)
|
||||
|
||||
try:
|
||||
session_id = await client.create_session("ag_1", "Slack C/1")
|
||||
await client.submit_message(session_id, "hello")
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
assert session_id == "conv_1"
|
||||
assert create.calls.last.request.headers["X-Forwarded-Email"] == "bot@example.com"
|
||||
assert create.calls.last.request.headers["Cookie"] == "ap_session=cookie-value"
|
||||
assert create.calls.last.request.read() == b'{"agent_id":"ag_1","title":"Slack C/1"}'
|
||||
assert submit.calls.last.request.read() == (
|
||||
b'{"type":"message","data":{"role":"user","content":[{"type":"input_text",'
|
||||
b'"text":"hello"}]}}'
|
||||
)
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_client_binds_random_runner() -> None:
|
||||
respx.get("http://omnigent.test/v1/hosts").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"hosts": [{"id": "host_1", "online": True, "runners": [{"id": "runner_a"}]}]},
|
||||
)
|
||||
)
|
||||
bind = respx.patch("http://omnigent.test/v1/sessions/conv_1").mock(
|
||||
return_value=httpx.Response(200, json={"id": "conv_1", "runner_id": "runner_a"})
|
||||
)
|
||||
client = OmnigentClient("http://omnigent.test")
|
||||
|
||||
try:
|
||||
runner_id = await client.bind_random_runner("conv_1")
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
assert runner_id == "runner_a"
|
||||
assert bind.calls.last.request.read() == b'{"runner_id":"runner_a"}'
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_client_launches_runner_when_no_online_runner_exists() -> None:
|
||||
respx.get("http://omnigent.test/v1/hosts").mock(
|
||||
return_value=httpx.Response(200, json={"hosts": []})
|
||||
)
|
||||
launch = respx.post("http://omnigent.test/v1/hosts/host_1/runners").mock(
|
||||
return_value=httpx.Response(200, json={"runner_id": "runner_launched"})
|
||||
)
|
||||
respx.get("http://omnigent.test/v1/runners/runner_launched/status").mock(
|
||||
return_value=httpx.Response(200, json={"runner_id": "runner_launched", "online": True})
|
||||
)
|
||||
client = OmnigentClient(
|
||||
"http://omnigent.test",
|
||||
runner_workspace="/tmp/workspace",
|
||||
runner_host_id="host_1",
|
||||
)
|
||||
|
||||
try:
|
||||
runner_id = await client.bind_random_runner("conv_1")
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
assert runner_id == "runner_launched"
|
||||
assert launch.calls.last.request.read() == (
|
||||
b'{"session_id":"conv_1","workspace":"/tmp/workspace"}'
|
||||
)
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_client_launches_runner_on_random_online_host() -> None:
|
||||
respx.get("http://omnigent.test/v1/hosts").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"hosts": [
|
||||
{"id": "host_offline", "status": "offline"},
|
||||
{"id": "host_online", "online": True},
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
launch = respx.post("http://omnigent.test/v1/hosts/host_online/runners").mock(
|
||||
return_value=httpx.Response(200, json={"runner_id": "runner_launched"})
|
||||
)
|
||||
respx.get("http://omnigent.test/v1/runners/runner_launched/status").mock(
|
||||
return_value=httpx.Response(200, json={"runner_id": "runner_launched", "online": True})
|
||||
)
|
||||
client = OmnigentClient("http://omnigent.test", runner_workspace="/tmp/workspace")
|
||||
|
||||
try:
|
||||
runner_id = await client.bind_random_runner("conv_1")
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
assert runner_id == "runner_launched"
|
||||
assert launch.called
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_client_binds_runner_loaded_from_hosts() -> None:
|
||||
respx.get("http://omnigent.test/v1/hosts").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"hosts": [
|
||||
{"id": "host_offline", "online": False, "runners": [{"id": "runner_no"}]},
|
||||
{
|
||||
"id": "host_online",
|
||||
"online": True,
|
||||
"runners": [{"runner_id": "runner_from_host"}],
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
bind = respx.patch("http://omnigent.test/v1/sessions/conv_1").mock(
|
||||
return_value=httpx.Response(200, json={"id": "conv_1"})
|
||||
)
|
||||
client = OmnigentClient("http://omnigent.test")
|
||||
|
||||
try:
|
||||
runner_id = await client.bind_random_runner("conv_1")
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
assert runner_id == "runner_from_host"
|
||||
assert bind.calls.last.request.read() == b'{"runner_id":"runner_from_host"}'
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_client_errors_when_no_runner_and_no_launch_workspace() -> None:
|
||||
respx.get("http://omnigent.test/v1/hosts").mock(
|
||||
return_value=httpx.Response(200, json={"hosts": []})
|
||||
)
|
||||
client = OmnigentClient("http://omnigent.test")
|
||||
|
||||
try:
|
||||
try:
|
||||
await client.bind_random_runner("conv_1")
|
||||
except OmnigentError as exc:
|
||||
message = str(exc)
|
||||
else:
|
||||
message = ""
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
assert "OMNIGENT_RUNNER_WORKSPACE" in message
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_run_turn_streams_across_multiple_responses_until_session_idle() -> None:
|
||||
# An orchestrator ends its first response to wait on a sub-agent, then
|
||||
# resumes with the real answer in a second response. The turn is only over
|
||||
# once the session settles to idle — `response.completed` alone must not
|
||||
# cut the stream off after the "dispatched, waiting" message.
|
||||
sse_body = (
|
||||
'data: {"type":"response.output_text.delta","delta":"Explorer dispatched."}\n\n'
|
||||
'data: {"type":"response.completed","response":{"status":"completed"}}\n\n'
|
||||
'data: {"type":"response.output_text.delta","delta":"Here is the report."}\n\n'
|
||||
'data: {"type":"response.completed","response":{"status":"completed"}}\n\n'
|
||||
'data: {"type":"session.status","conversation_id":"conv_1","status":"idle"}\n\n'
|
||||
"data: [DONE]\n\n"
|
||||
)
|
||||
respx.get("http://omnigent.test/v1/sessions/conv_1/stream").mock(
|
||||
return_value=httpx.Response(200, text=sse_body)
|
||||
)
|
||||
respx.post("http://omnigent.test/v1/sessions/conv_1/events").mock(
|
||||
return_value=httpx.Response(200, json={})
|
||||
)
|
||||
client = OmnigentClient("http://omnigent.test")
|
||||
|
||||
try:
|
||||
deltas = [
|
||||
event.get("delta")
|
||||
async for event in client.run_turn("conv_1", "hello")
|
||||
if event.get("type") == "response.output_text.delta"
|
||||
]
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
# Both responses stream; the second (the real answer) is not dropped.
|
||||
assert deltas == ["Explorer dispatched.", "Here is the report."]
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_client_raises_runner_unavailable() -> None:
|
||||
respx.post("http://omnigent.test/v1/sessions/conv_1/events").mock(
|
||||
return_value=httpx.Response(
|
||||
503,
|
||||
json={"error": {"code": "runner_unavailable", "message": "No runner bound"}},
|
||||
)
|
||||
)
|
||||
client = OmnigentClient("http://omnigent.test")
|
||||
|
||||
try:
|
||||
try:
|
||||
await client.submit_message("conv_1", "hello")
|
||||
except RunnerUnavailableError:
|
||||
raised = True
|
||||
else:
|
||||
raised = False
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
assert raised is True
|
||||
@@ -1,479 +0,0 @@
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from omnigent_slack.models import ThreadKey
|
||||
from omnigent_slack.service import SlackOmnigentService
|
||||
from omnigent_slack.store import SQLiteStore
|
||||
|
||||
|
||||
class FakeSlackClient:
|
||||
def __init__(self) -> None:
|
||||
self.posts: list[dict[str, Any]] = []
|
||||
self.updates: list[dict[str, Any]] = []
|
||||
|
||||
async def chat_postMessage(self, **kwargs: Any) -> dict[str, Any]:
|
||||
self.posts.append(kwargs)
|
||||
return {"ok": True, "ts": f"bot-{len(self.posts)}"}
|
||||
|
||||
async def chat_update(self, **kwargs: Any) -> dict[str, Any]:
|
||||
self.updates.append(kwargs)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# Slack accepts up to 40,000 characters in a message ``text`` and rejects
|
||||
# anything larger with ``msg_too_long`` (docs.slack.dev chat.postMessage).
|
||||
SLACK_HARD_LIMIT = 40000
|
||||
|
||||
|
||||
class LimitEnforcingSlackClient(FakeSlackClient):
|
||||
"""Fake that mimics Slack rejecting oversized ``text`` with msg_too_long."""
|
||||
|
||||
async def chat_postMessage(self, **kwargs: Any) -> dict[str, Any]:
|
||||
self._enforce_limit(kwargs)
|
||||
return await super().chat_postMessage(**kwargs)
|
||||
|
||||
async def chat_update(self, **kwargs: Any) -> dict[str, Any]:
|
||||
self._enforce_limit(kwargs)
|
||||
return await super().chat_update(**kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _enforce_limit(kwargs: dict[str, Any]) -> None:
|
||||
if len(str(kwargs.get("text") or "")) > SLACK_HARD_LIMIT:
|
||||
raise RuntimeError("msg_too_long")
|
||||
|
||||
|
||||
class FlakyUpdateSlackClient(FakeSlackClient):
|
||||
"""Fake whose streaming ``chat_update`` calls fail before the final one.
|
||||
|
||||
The final delivery re-edits the placeholder to the first chunk; only the
|
||||
interim progress updates raise, so we can prove a progress-update failure
|
||||
never aborts the turn or clobbers the real answer.
|
||||
"""
|
||||
|
||||
def __init__(self, fail_first: int = 1) -> None:
|
||||
super().__init__()
|
||||
self._remaining_failures = fail_first
|
||||
|
||||
async def chat_update(self, **kwargs: Any) -> dict[str, Any]:
|
||||
if self._remaining_failures > 0:
|
||||
self._remaining_failures -= 1
|
||||
raise RuntimeError("msg_too_long")
|
||||
return await super().chat_update(**kwargs)
|
||||
|
||||
|
||||
class FakeOmnigentClient:
|
||||
def __init__(self, final_text: str = "hello final") -> None:
|
||||
self.created: list[tuple[str, str]] = []
|
||||
self.bound: list[str] = []
|
||||
self.turns: list[tuple[str, str]] = []
|
||||
self.next_session_id = "conv_1"
|
||||
self.final_text = final_text
|
||||
|
||||
async def create_session(self, agent_id: str, title: str) -> str:
|
||||
self.created.append((agent_id, title))
|
||||
return self.next_session_id
|
||||
|
||||
async def bind_random_runner(self, session_id: str) -> str:
|
||||
self.bound.append(session_id)
|
||||
return "runner_1"
|
||||
|
||||
async def run_turn(self, session_id: str, text: str) -> AsyncIterator[dict[str, Any]]:
|
||||
self.turns.append((session_id, text))
|
||||
yield {"type": "response.output_text.delta", "delta": "hel"}
|
||||
yield {"type": "response.output_text.delta", "delta": "lo"}
|
||||
yield {
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": self.final_text}],
|
||||
},
|
||||
}
|
||||
yield {"type": "response.completed", "response": {"status": "completed"}}
|
||||
|
||||
async def latest_assistant_text(self, session_id: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _store(tmp_path: Path) -> SQLiteStore:
|
||||
store = SQLiteStore(tmp_path / "store.sqlite3")
|
||||
await store.initialize()
|
||||
return store
|
||||
|
||||
|
||||
async def _wait_for_updates(client: FakeSlackClient, count: int) -> None:
|
||||
for _ in range(50):
|
||||
if len(client.updates) >= count:
|
||||
return
|
||||
await asyncio.sleep(0.02)
|
||||
raise AssertionError(f"Timed out waiting for {count} updates")
|
||||
|
||||
|
||||
async def test_app_mention_creates_session_and_posts_response(tmp_path: Path) -> None:
|
||||
store = await _store(tmp_path)
|
||||
slack = FakeSlackClient()
|
||||
omnigent = FakeOmnigentClient()
|
||||
service = SlackOmnigentService(
|
||||
store=store,
|
||||
omnigent=omnigent, # type: ignore[arg-type]
|
||||
omnigent_agent_id="ag_1",
|
||||
update_interval_seconds=0,
|
||||
)
|
||||
|
||||
await service.handle_app_mention(
|
||||
body={"team_id": "T1", "event_id": "Ev1"},
|
||||
event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> hello"},
|
||||
client=slack,
|
||||
context={"bot_user_id": "B1"},
|
||||
)
|
||||
await _wait_for_updates(slack, 2)
|
||||
await service.shutdown()
|
||||
|
||||
key = ThreadKey(team_id="T1", channel_id="C1", thread_ts="100.1")
|
||||
assert await store.get_session_id(key) == "conv_1"
|
||||
assert omnigent.created[0][0] == "ag_1"
|
||||
assert omnigent.bound == ["conv_1"]
|
||||
assert omnigent.turns == [("conv_1", "hello")]
|
||||
assert slack.posts[0]["thread_ts"] == "100.1"
|
||||
assert slack.updates[-1]["text"] == "hello final"
|
||||
|
||||
|
||||
async def test_long_answer_is_split_across_thread_replies(tmp_path: Path) -> None:
|
||||
from omnigent_slack.text import SLACK_MESSAGE_CHAR_LIMIT
|
||||
|
||||
store = await _store(tmp_path)
|
||||
slack = FakeSlackClient()
|
||||
long_answer = "x" * (SLACK_MESSAGE_CHAR_LIMIT * 2 + 100)
|
||||
omnigent = FakeOmnigentClient(final_text=long_answer)
|
||||
service = SlackOmnigentService(
|
||||
store=store,
|
||||
omnigent=omnigent, # type: ignore[arg-type]
|
||||
omnigent_agent_id="ag_1",
|
||||
update_interval_seconds=0,
|
||||
)
|
||||
|
||||
await service.handle_app_mention(
|
||||
body={"team_id": "T1", "event_id": "Ev1"},
|
||||
event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> hello"},
|
||||
client=slack,
|
||||
context={"bot_user_id": "B1"},
|
||||
)
|
||||
await _wait_for_updates(slack, 1)
|
||||
# Placeholder update + two overflow replies = original placeholder post + 2.
|
||||
for _ in range(50):
|
||||
if len(slack.posts) >= 3:
|
||||
break
|
||||
await asyncio.sleep(0.02)
|
||||
await service.shutdown()
|
||||
|
||||
# Every message stays within Slack's limit and the full answer is preserved.
|
||||
parts = [slack.updates[-1]["text"]]
|
||||
parts.extend(post["text"] for post in slack.posts[1:])
|
||||
assert all(len(part) <= SLACK_MESSAGE_CHAR_LIMIT for part in parts)
|
||||
assert "".join(parts) == long_answer
|
||||
# Overflow replies land in the same thread.
|
||||
assert all(post["thread_ts"] == "100.1" for post in slack.posts[1:])
|
||||
|
||||
|
||||
class StreamingLongAnswerClient(FakeOmnigentClient):
|
||||
"""Streams a long answer as deltas, then reports it as the final item.
|
||||
|
||||
Mirrors the real session where an orchestrator streams a multi-thousand
|
||||
character synthesis: the interim progress updates carry the whole
|
||||
accumulated text, which overflows Slack's ``text`` ceiling.
|
||||
"""
|
||||
|
||||
async def run_turn(self, session_id: str, text: str) -> AsyncIterator[dict[str, Any]]:
|
||||
self.turns.append((session_id, text))
|
||||
# Two deltas so the accumulated progress text is multi-thousand chars.
|
||||
half = "a" * 3000
|
||||
yield {"type": "response.output_text.delta", "delta": half}
|
||||
yield {"type": "response.output_text.delta", "delta": half}
|
||||
yield {
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": self.final_text}],
|
||||
},
|
||||
}
|
||||
yield {"type": "response.completed", "response": {"status": "completed"}}
|
||||
|
||||
|
||||
async def test_streaming_progress_update_failure_does_not_clobber_answer(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = await _store(tmp_path)
|
||||
slack = FlakyUpdateSlackClient(fail_first=1)
|
||||
omnigent = StreamingLongAnswerClient(final_text="the real answer")
|
||||
service = SlackOmnigentService(
|
||||
store=store,
|
||||
omnigent=omnigent, # type: ignore[arg-type]
|
||||
omnigent_agent_id="ag_1",
|
||||
update_interval_seconds=0,
|
||||
)
|
||||
|
||||
await service.handle_app_mention(
|
||||
body={"team_id": "T1", "event_id": "Ev1"},
|
||||
event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> hello"},
|
||||
client=slack,
|
||||
context={"bot_user_id": "B1"},
|
||||
)
|
||||
await _wait_for_updates(slack, 1)
|
||||
await service.shutdown()
|
||||
|
||||
# A failed progress update must not surface as an error answer.
|
||||
assert slack.updates[-1]["text"] == "the real answer"
|
||||
|
||||
|
||||
async def test_long_streamed_answer_never_exceeds_slack_limit(tmp_path: Path) -> None:
|
||||
from omnigent_slack.text import SLACK_MESSAGE_CHAR_LIMIT
|
||||
|
||||
# Every delivered chunk stays within Slack's hard ceiling.
|
||||
assert SLACK_MESSAGE_CHAR_LIMIT <= SLACK_HARD_LIMIT
|
||||
|
||||
store = await _store(tmp_path)
|
||||
slack = LimitEnforcingSlackClient()
|
||||
long_answer = "y" * (SLACK_MESSAGE_CHAR_LIMIT * 2 + 100)
|
||||
omnigent = StreamingLongAnswerClient(final_text=long_answer)
|
||||
service = SlackOmnigentService(
|
||||
store=store,
|
||||
omnigent=omnigent, # type: ignore[arg-type]
|
||||
omnigent_agent_id="ag_1",
|
||||
update_interval_seconds=0,
|
||||
)
|
||||
|
||||
await service.handle_app_mention(
|
||||
body={"team_id": "T1", "event_id": "Ev1"},
|
||||
event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> hello"},
|
||||
client=slack,
|
||||
context={"bot_user_id": "B1"},
|
||||
)
|
||||
await _wait_for_updates(slack, 1)
|
||||
for _ in range(50):
|
||||
if len(slack.posts) >= 3:
|
||||
break
|
||||
await asyncio.sleep(0.02)
|
||||
await service.shutdown()
|
||||
|
||||
# No update or post exceeded the hard limit (else the fake would have
|
||||
# raised), every chunk honors the best-practice ceiling, and the full
|
||||
# answer was preserved across parts.
|
||||
parts = [slack.updates[-1]["text"]]
|
||||
parts.extend(post["text"] for post in slack.posts[1:])
|
||||
assert all(len(part) <= SLACK_MESSAGE_CHAR_LIMIT for part in parts)
|
||||
assert "".join(parts) == long_answer
|
||||
|
||||
|
||||
async def test_turn_error_posts_separate_reply_and_keeps_answer(tmp_path: Path) -> None:
|
||||
"""An error after content streamed must not erase the delivered answer.
|
||||
|
||||
The failure is reported as its own thread reply so the user keeps both the
|
||||
real answer and the failure notice.
|
||||
"""
|
||||
store = await _store(tmp_path)
|
||||
slack = FakeSlackClient()
|
||||
|
||||
class ErroringAfterAnswerClient(FakeOmnigentClient):
|
||||
async def run_turn(self, session_id: str, text: str) -> AsyncIterator[dict[str, Any]]:
|
||||
self.turns.append((session_id, text))
|
||||
yield {
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": self.final_text}],
|
||||
},
|
||||
}
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {"error": {"message": "boom"}},
|
||||
}
|
||||
|
||||
omnigent = ErroringAfterAnswerClient(final_text="the real answer")
|
||||
service = SlackOmnigentService(
|
||||
store=store,
|
||||
omnigent=omnigent, # type: ignore[arg-type]
|
||||
omnigent_agent_id="ag_1",
|
||||
update_interval_seconds=0,
|
||||
)
|
||||
|
||||
await service.handle_app_mention(
|
||||
body={"team_id": "T1", "event_id": "Ev1"},
|
||||
event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> hello"},
|
||||
client=slack,
|
||||
context={"bot_user_id": "B1"},
|
||||
)
|
||||
await _wait_for_updates(slack, 1)
|
||||
for _ in range(50):
|
||||
if len(slack.posts) >= 2:
|
||||
break
|
||||
await asyncio.sleep(0.02)
|
||||
await service.shutdown()
|
||||
|
||||
# The placeholder holds the real answer, not the error.
|
||||
assert slack.updates[-1]["text"] == "the real answer"
|
||||
# The failure is a separate reply in the same thread.
|
||||
failure_posts = [p for p in slack.posts if "failed" in str(p.get("text", ""))]
|
||||
assert len(failure_posts) == 1
|
||||
assert "boom" in failure_posts[0]["text"]
|
||||
assert failure_posts[0]["thread_ts"] == "100.1"
|
||||
|
||||
|
||||
async def test_turn_error_without_answer_uses_placeholder(tmp_path: Path) -> None:
|
||||
"""When nothing streamed, the error surfaces in the placeholder itself."""
|
||||
store = await _store(tmp_path)
|
||||
slack = FakeSlackClient()
|
||||
|
||||
class ErroringNoAnswerClient(FakeOmnigentClient):
|
||||
async def run_turn(self, session_id: str, text: str) -> AsyncIterator[dict[str, Any]]:
|
||||
self.turns.append((session_id, text))
|
||||
yield {
|
||||
"type": "response.failed",
|
||||
"response": {"error": {"message": "boom"}},
|
||||
}
|
||||
|
||||
async def latest_assistant_text(self, session_id: str) -> str | None:
|
||||
return None
|
||||
|
||||
omnigent = ErroringNoAnswerClient()
|
||||
service = SlackOmnigentService(
|
||||
store=store,
|
||||
omnigent=omnigent, # type: ignore[arg-type]
|
||||
omnigent_agent_id="ag_1",
|
||||
update_interval_seconds=0,
|
||||
)
|
||||
|
||||
await service.handle_app_mention(
|
||||
body={"team_id": "T1", "event_id": "Ev1"},
|
||||
event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> hello"},
|
||||
client=slack,
|
||||
context={"bot_user_id": "B1"},
|
||||
)
|
||||
await _wait_for_updates(slack, 1)
|
||||
await service.shutdown()
|
||||
|
||||
assert "boom" in slack.updates[-1]["text"]
|
||||
# No extra failure reply when there was no answer to preserve.
|
||||
assert len(slack.posts) == 1
|
||||
|
||||
|
||||
async def test_empty_app_mention_prompts_without_creating_session(tmp_path: Path) -> None:
|
||||
store = await _store(tmp_path)
|
||||
slack = FakeSlackClient()
|
||||
omnigent = FakeOmnigentClient()
|
||||
service = SlackOmnigentService(
|
||||
store=store,
|
||||
omnigent=omnigent, # type: ignore[arg-type]
|
||||
omnigent_agent_id="ag_1",
|
||||
)
|
||||
|
||||
await service.handle_app_mention(
|
||||
body={"team_id": "T1", "event_id": "Ev1"},
|
||||
event={"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1>"},
|
||||
client=slack,
|
||||
context={"bot_user_id": "B1"},
|
||||
)
|
||||
await service.shutdown()
|
||||
|
||||
assert omnigent.created == []
|
||||
assert omnigent.bound == []
|
||||
assert "Send a message" in slack.posts[0]["text"]
|
||||
|
||||
|
||||
async def test_thread_reply_reuses_existing_session(tmp_path: Path) -> None:
|
||||
store = await _store(tmp_path)
|
||||
key = ThreadKey(team_id="T1", channel_id="C1", thread_ts="100.1")
|
||||
await store.upsert_session(key, "conv_existing", "title")
|
||||
slack = FakeSlackClient()
|
||||
omnigent = FakeOmnigentClient()
|
||||
service = SlackOmnigentService(
|
||||
store=store,
|
||||
omnigent=omnigent, # type: ignore[arg-type]
|
||||
omnigent_agent_id="ag_1",
|
||||
update_interval_seconds=0,
|
||||
)
|
||||
|
||||
await service.handle_message(
|
||||
body={"team_id": "T1", "event_id": "Ev2"},
|
||||
event={
|
||||
"channel": "C1",
|
||||
"thread_ts": "100.1",
|
||||
"ts": "101.1",
|
||||
"user": "U1",
|
||||
"text": "next",
|
||||
},
|
||||
client=slack,
|
||||
context={"bot_user_id": "B1"},
|
||||
)
|
||||
await _wait_for_updates(slack, 2)
|
||||
await service.shutdown()
|
||||
|
||||
assert omnigent.created == []
|
||||
assert omnigent.bound == []
|
||||
assert omnigent.turns == [("conv_existing", "next")]
|
||||
assert slack.updates[-1]["text"] == "hello final"
|
||||
|
||||
|
||||
async def test_duplicate_event_is_ignored(tmp_path: Path) -> None:
|
||||
store = await _store(tmp_path)
|
||||
slack = FakeSlackClient()
|
||||
omnigent = FakeOmnigentClient()
|
||||
service = SlackOmnigentService(
|
||||
store=store,
|
||||
omnigent=omnigent, # type: ignore[arg-type]
|
||||
omnigent_agent_id="ag_1",
|
||||
update_interval_seconds=0,
|
||||
)
|
||||
body = {"team_id": "T1", "event_id": "Ev1"}
|
||||
event = {"channel": "C1", "ts": "100.1", "user": "U1", "text": "<@B1> hello"}
|
||||
|
||||
await service.handle_app_mention(
|
||||
body=body,
|
||||
event=event,
|
||||
client=slack,
|
||||
context={"bot_user_id": "B1"},
|
||||
)
|
||||
await service.handle_app_mention(
|
||||
body=body,
|
||||
event=event,
|
||||
client=slack,
|
||||
context={"bot_user_id": "B1"},
|
||||
)
|
||||
await _wait_for_updates(slack, 2)
|
||||
await service.shutdown()
|
||||
|
||||
assert len(omnigent.turns) == 1
|
||||
|
||||
|
||||
async def test_generic_message_with_bot_mention_is_ignored(tmp_path: Path) -> None:
|
||||
store = await _store(tmp_path)
|
||||
key = ThreadKey(team_id="T1", channel_id="C1", thread_ts="100.1")
|
||||
await store.upsert_session(key, "conv_existing", "title")
|
||||
slack = FakeSlackClient()
|
||||
omnigent = FakeOmnigentClient()
|
||||
service = SlackOmnigentService(
|
||||
store=store,
|
||||
omnigent=omnigent, # type: ignore[arg-type]
|
||||
omnigent_agent_id="ag_1",
|
||||
update_interval_seconds=0,
|
||||
)
|
||||
|
||||
await service.handle_message(
|
||||
body={"team_id": "T1", "event_id": "Ev2"},
|
||||
event={
|
||||
"channel": "C1",
|
||||
"thread_ts": "100.1",
|
||||
"ts": "101.1",
|
||||
"user": "U1",
|
||||
"text": "<@B1> next",
|
||||
},
|
||||
client=slack,
|
||||
context={"bot_user_id": "B1"},
|
||||
)
|
||||
await service.shutdown()
|
||||
|
||||
assert omnigent.turns == []
|
||||
assert slack.posts == []
|
||||
@@ -1,27 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from omnigent_slack.models import ThreadKey
|
||||
from omnigent_slack.store import SQLiteStore
|
||||
|
||||
|
||||
async def test_store_persists_thread_session(tmp_path: Path) -> None:
|
||||
store = SQLiteStore(tmp_path / "store.sqlite3")
|
||||
await store.initialize()
|
||||
|
||||
key = ThreadKey(team_id="T1", channel_id="C1", thread_ts="100.1")
|
||||
assert await store.get_session_id(key) is None
|
||||
|
||||
await store.upsert_session(key, "conv_1", "title")
|
||||
assert await store.get_session_id(key) == "conv_1"
|
||||
|
||||
await store.upsert_session(key, "conv_2", "title")
|
||||
assert await store.get_session_id(key) == "conv_2"
|
||||
|
||||
|
||||
async def test_store_claim_event_dedupes(tmp_path: Path) -> None:
|
||||
store = SQLiteStore(tmp_path / "store.sqlite3")
|
||||
await store.initialize()
|
||||
|
||||
assert await store.claim_event("Ev1") is True
|
||||
assert await store.claim_event("Ev1") is False
|
||||
assert await store.claim_event(None) is True
|
||||
@@ -1,63 +0,0 @@
|
||||
from omnigent_slack.text import (
|
||||
split_for_slack,
|
||||
strip_bot_mention,
|
||||
to_mrkdwn,
|
||||
truncate_for_slack,
|
||||
)
|
||||
|
||||
|
||||
def test_to_mrkdwn_converts_markdown_to_slack_dialect() -> None:
|
||||
result = to_mrkdwn("# Title\n\n**bold** and [link](https://example.com)")
|
||||
# Bold collapses to single asterisks, headings lose '#', links become <url|text>.
|
||||
assert "**" not in result
|
||||
assert "*bold*" in result
|
||||
assert "<https://example.com|link>" in result
|
||||
assert "#" not in result
|
||||
|
||||
|
||||
def test_to_mrkdwn_preserves_code_blocks() -> None:
|
||||
result = to_mrkdwn("```python\nprint('hi')\n```")
|
||||
assert "```" in result
|
||||
assert "print('hi')" in result
|
||||
|
||||
|
||||
def test_strip_bot_mention_removes_target_mention() -> None:
|
||||
assert strip_bot_mention("<@B123> hello world", "B123") == "hello world"
|
||||
|
||||
|
||||
def test_strip_bot_mention_falls_back_to_first_mention() -> None:
|
||||
assert strip_bot_mention("<@B123> hello <@U456>", None) == "hello <@U456>"
|
||||
|
||||
|
||||
def test_truncate_for_slack() -> None:
|
||||
result = truncate_for_slack("a" * 20, limit=15)
|
||||
assert result.endswith("[truncated]")
|
||||
assert len(result) <= 15
|
||||
|
||||
|
||||
def test_split_for_slack_returns_single_chunk_when_within_limit() -> None:
|
||||
assert split_for_slack("hello", limit=10) == ["hello"]
|
||||
|
||||
|
||||
def test_split_for_slack_empty_yields_one_empty_chunk() -> None:
|
||||
assert split_for_slack("", limit=10) == [""]
|
||||
|
||||
|
||||
def test_split_for_slack_preserves_all_content_and_respects_limit() -> None:
|
||||
text = "\n".join(f"line {i}" for i in range(200))
|
||||
chunks = split_for_slack(text, limit=40)
|
||||
assert all(len(chunk) <= 40 for chunk in chunks)
|
||||
assert "".join(chunks) == text
|
||||
|
||||
|
||||
def test_split_for_slack_breaks_on_whitespace_when_possible() -> None:
|
||||
chunks = split_for_slack("aaaa bbbb cccc", limit=6)
|
||||
# Breaks after a space rather than mid-word.
|
||||
assert chunks[0] == "aaaa "
|
||||
assert "".join(chunks) == "aaaa bbbb cccc"
|
||||
|
||||
|
||||
def test_split_for_slack_hard_cuts_runs_without_whitespace() -> None:
|
||||
text = "a" * 25
|
||||
chunks = split_for_slack(text, limit=10)
|
||||
assert chunks == ["a" * 10, "a" * 10, "a" * 5]
|
||||
Generated
-1214
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
"""API schemas (protobuf) for omnigent services.
|
||||
|
||||
Each API lives in its own subpackage, versioned to mirror its proto ``package``
|
||||
declaration — e.g. the routing API's ``package omnigent.api.routing.v1`` maps to
|
||||
``omnigent/api/routing/v1/``. A new major version is a sibling ``vN`` package.
|
||||
"""
|
||||
@@ -1,2 +0,0 @@
|
||||
"""AI-gateway routing API schema. Versioned subpackages (``v1``, ...) hold the
|
||||
proto and its generated bindings."""
|
||||
@@ -1,2 +0,0 @@
|
||||
"""Routing API v1 (``package omnigent.api.routing.v1``): the ``routing.proto``
|
||||
schema and its generated ``routing_pb2`` bindings."""
|
||||
@@ -1,110 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
// Routing API for AI gateways.
|
||||
//
|
||||
// This schema is versioned independently of any particular gateway
|
||||
// implementation so that the request and response contracts can evolve
|
||||
// (v1, v2, ...) without being coupled to a gateway's release cycle, while
|
||||
// still being served under a gateway's existing API scope.
|
||||
package omnigent.api.routing.v1;
|
||||
|
||||
import "google/protobuf/struct.proto";
|
||||
|
||||
// A candidate destination a request may be routed to.
|
||||
message RouteOption {
|
||||
// Identifier of the model to serve the request, e.g. "gpt-5-5".
|
||||
optional string model = 1;
|
||||
|
||||
// Harness that drives the model. May be omitted for a native harness;
|
||||
// required when the model is served through a meta-harness.
|
||||
optional string harness = 2;
|
||||
}
|
||||
|
||||
// Selects the routing strategy to apply and its configuration.
|
||||
//
|
||||
// A gateway resolves `router_name` to a routing implementation and passes
|
||||
// `config` to it. Both the set of available routers and their configuration
|
||||
// are gateway-defined, so `config` is an opaque structure rather than a fixed
|
||||
// message. Examples:
|
||||
//
|
||||
// Server-side judge model (implementation is a gateway-side black box):
|
||||
// {
|
||||
// "router_name": "judge_v20260708",
|
||||
// "config": {
|
||||
// "user_guidance": "prefer the most capable model available",
|
||||
// "org_guidance": "keep cost reasonable"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Fixed model and harness:
|
||||
// {
|
||||
// "router_name": "fixed",
|
||||
// "config": { "model": "gpt-5-5", "harness": "codex" }
|
||||
// }
|
||||
//
|
||||
// User-supplied lambda:
|
||||
// {
|
||||
// "router_name": "python_lambda",
|
||||
// "config": { "lambda": "gpt-5-5 if len(prompt) > 10 else kimi" }
|
||||
// }
|
||||
message RouteSelector {
|
||||
// Name of the routing strategy to invoke.
|
||||
optional string router_name = 1;
|
||||
|
||||
// Router-specific configuration, interpreted by the selected router.
|
||||
optional google.protobuf.Struct config = 2;
|
||||
}
|
||||
|
||||
// The routing decision produced for a single request.
|
||||
message RouteSelection {
|
||||
// The chosen destination.
|
||||
optional RouteOption route_option = 1;
|
||||
|
||||
// Router-specific parameters emitted alongside the decision, interpreted
|
||||
// by the caller or the serving path.
|
||||
optional google.protobuf.Struct params = 2;
|
||||
}
|
||||
|
||||
// A single unit of work submitted for routing.
|
||||
message Task {
|
||||
// The prompt to be served.
|
||||
optional string prompt = 1;
|
||||
}
|
||||
|
||||
// One prior turn in a session: the task that was submitted and the routing
|
||||
// decision made for it.
|
||||
message SessionTurn {
|
||||
optional Task task = 1;
|
||||
optional RouteSelection route_selection = 2;
|
||||
}
|
||||
|
||||
// The routing history for a session, ordered oldest to newest. Routers may
|
||||
// use it to keep successive turns consistent.
|
||||
message SessionHistory {
|
||||
repeated SessionTurn session_turns = 1;
|
||||
}
|
||||
|
||||
// Request to select a route for a task.
|
||||
message SelectRouteRequest {
|
||||
// Candidate destinations the router may choose from.
|
||||
repeated RouteOption route_options = 1;
|
||||
|
||||
// The task to route.
|
||||
optional Task task = 2;
|
||||
|
||||
// The routing strategy to apply. Required in practice; a gateway rejects a
|
||||
// request that omits it.
|
||||
optional RouteSelector route_selector = 3;
|
||||
|
||||
// Prior turns in the session, when available.
|
||||
optional SessionHistory session_history = 4;
|
||||
}
|
||||
|
||||
// Response containing the selected route.
|
||||
message SelectRouteResponse {
|
||||
// The routing decision.
|
||||
repeated RouteSelection route_selection = 1;
|
||||
|
||||
// Human-readable explanation of why this route was selected.
|
||||
optional string rationale = 2;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: omnigent/api/routing/v1/routing.proto
|
||||
# Protobuf Python Version: 6.33.5
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import runtime_version as _runtime_version
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
6,
|
||||
33,
|
||||
5,
|
||||
'',
|
||||
'omnigent/api/routing/v1/routing.proto'
|
||||
)
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n%omnigent/api/routing/v1/routing.proto\x12\x17omnigent.api.routing.v1\x1a\x1cgoogle/protobuf/struct.proto\"M\n\x0bRouteOption\x12\x12\n\x05model\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x14\n\x07harness\x18\x02 \x01(\tH\x01\x88\x01\x01\x42\x08\n\x06_modelB\n\n\x08_harness\"r\n\rRouteSelector\x12\x18\n\x0brouter_name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12,\n\x06\x63onfig\x18\x02 \x01(\x0b\x32\x17.google.protobuf.StructH\x01\x88\x01\x01\x42\x0e\n\x0c_router_nameB\t\n\x07_config\"\x9b\x01\n\x0eRouteSelection\x12?\n\x0croute_option\x18\x01 \x01(\x0b\x32$.omnigent.api.routing.v1.RouteOptionH\x00\x88\x01\x01\x12,\n\x06params\x18\x02 \x01(\x0b\x32\x17.google.protobuf.StructH\x01\x88\x01\x01\x42\x0f\n\r_route_optionB\t\n\x07_params\"&\n\x04Task\x12\x13\n\x06prompt\x18\x01 \x01(\tH\x00\x88\x01\x01\x42\t\n\x07_prompt\"\xa3\x01\n\x0bSessionTurn\x12\x30\n\x04task\x18\x01 \x01(\x0b\x32\x1d.omnigent.api.routing.v1.TaskH\x00\x88\x01\x01\x12\x45\n\x0froute_selection\x18\x02 \x01(\x0b\x32\'.omnigent.api.routing.v1.RouteSelectionH\x01\x88\x01\x01\x42\x07\n\x05_taskB\x12\n\x10_route_selection\"M\n\x0eSessionHistory\x12;\n\rsession_turns\x18\x01 \x03(\x0b\x32$.omnigent.api.routing.v1.SessionTurn\"\xbf\x02\n\x12SelectRouteRequest\x12;\n\rroute_options\x18\x01 \x03(\x0b\x32$.omnigent.api.routing.v1.RouteOption\x12\x30\n\x04task\x18\x02 \x01(\x0b\x32\x1d.omnigent.api.routing.v1.TaskH\x00\x88\x01\x01\x12\x43\n\x0eroute_selector\x18\x03 \x01(\x0b\x32&.omnigent.api.routing.v1.RouteSelectorH\x01\x88\x01\x01\x12\x45\n\x0fsession_history\x18\x04 \x01(\x0b\x32\'.omnigent.api.routing.v1.SessionHistoryH\x02\x88\x01\x01\x42\x07\n\x05_taskB\x11\n\x0f_route_selectorB\x12\n\x10_session_history\"}\n\x13SelectRouteResponse\x12@\n\x0froute_selection\x18\x01 \x03(\x0b\x32\'.omnigent.api.routing.v1.RouteSelection\x12\x16\n\trationale\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0c\n\n_rationaleb\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'omnigent.api.routing.v1.routing_pb2', _globals)
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
DESCRIPTOR._loaded_options = None
|
||||
_globals['_ROUTEOPTION']._serialized_start=96
|
||||
_globals['_ROUTEOPTION']._serialized_end=173
|
||||
_globals['_ROUTESELECTOR']._serialized_start=175
|
||||
_globals['_ROUTESELECTOR']._serialized_end=289
|
||||
_globals['_ROUTESELECTION']._serialized_start=292
|
||||
_globals['_ROUTESELECTION']._serialized_end=447
|
||||
_globals['_TASK']._serialized_start=449
|
||||
_globals['_TASK']._serialized_end=487
|
||||
_globals['_SESSIONTURN']._serialized_start=490
|
||||
_globals['_SESSIONTURN']._serialized_end=653
|
||||
_globals['_SESSIONHISTORY']._serialized_start=655
|
||||
_globals['_SESSIONHISTORY']._serialized_end=732
|
||||
_globals['_SELECTROUTEREQUEST']._serialized_start=735
|
||||
_globals['_SELECTROUTEREQUEST']._serialized_end=1054
|
||||
_globals['_SELECTROUTERESPONSE']._serialized_start=1056
|
||||
_globals['_SELECTROUTERESPONSE']._serialized_end=1181
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -1,72 +0,0 @@
|
||||
from google.protobuf import struct_pb2 as _struct_pb2
|
||||
from google.protobuf.internal import containers as _containers
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from collections.abc import Iterable as _Iterable, Mapping as _Mapping
|
||||
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
|
||||
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class RouteOption(_message.Message):
|
||||
__slots__ = ("model", "harness")
|
||||
MODEL_FIELD_NUMBER: _ClassVar[int]
|
||||
HARNESS_FIELD_NUMBER: _ClassVar[int]
|
||||
model: str
|
||||
harness: str
|
||||
def __init__(self, model: _Optional[str] = ..., harness: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class RouteSelector(_message.Message):
|
||||
__slots__ = ("router_name", "config")
|
||||
ROUTER_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
CONFIG_FIELD_NUMBER: _ClassVar[int]
|
||||
router_name: str
|
||||
config: _struct_pb2.Struct
|
||||
def __init__(self, router_name: _Optional[str] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class RouteSelection(_message.Message):
|
||||
__slots__ = ("route_option", "params")
|
||||
ROUTE_OPTION_FIELD_NUMBER: _ClassVar[int]
|
||||
PARAMS_FIELD_NUMBER: _ClassVar[int]
|
||||
route_option: RouteOption
|
||||
params: _struct_pb2.Struct
|
||||
def __init__(self, route_option: _Optional[_Union[RouteOption, _Mapping]] = ..., params: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class Task(_message.Message):
|
||||
__slots__ = ("prompt",)
|
||||
PROMPT_FIELD_NUMBER: _ClassVar[int]
|
||||
prompt: str
|
||||
def __init__(self, prompt: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class SessionTurn(_message.Message):
|
||||
__slots__ = ("task", "route_selection")
|
||||
TASK_FIELD_NUMBER: _ClassVar[int]
|
||||
ROUTE_SELECTION_FIELD_NUMBER: _ClassVar[int]
|
||||
task: Task
|
||||
route_selection: RouteSelection
|
||||
def __init__(self, task: _Optional[_Union[Task, _Mapping]] = ..., route_selection: _Optional[_Union[RouteSelection, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class SessionHistory(_message.Message):
|
||||
__slots__ = ("session_turns",)
|
||||
SESSION_TURNS_FIELD_NUMBER: _ClassVar[int]
|
||||
session_turns: _containers.RepeatedCompositeFieldContainer[SessionTurn]
|
||||
def __init__(self, session_turns: _Optional[_Iterable[_Union[SessionTurn, _Mapping]]] = ...) -> None: ...
|
||||
|
||||
class SelectRouteRequest(_message.Message):
|
||||
__slots__ = ("route_options", "task", "route_selector", "session_history")
|
||||
ROUTE_OPTIONS_FIELD_NUMBER: _ClassVar[int]
|
||||
TASK_FIELD_NUMBER: _ClassVar[int]
|
||||
ROUTE_SELECTOR_FIELD_NUMBER: _ClassVar[int]
|
||||
SESSION_HISTORY_FIELD_NUMBER: _ClassVar[int]
|
||||
route_options: _containers.RepeatedCompositeFieldContainer[RouteOption]
|
||||
task: Task
|
||||
route_selector: RouteSelector
|
||||
session_history: SessionHistory
|
||||
def __init__(self, route_options: _Optional[_Iterable[_Union[RouteOption, _Mapping]]] = ..., task: _Optional[_Union[Task, _Mapping]] = ..., route_selector: _Optional[_Union[RouteSelector, _Mapping]] = ..., session_history: _Optional[_Union[SessionHistory, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class SelectRouteResponse(_message.Message):
|
||||
__slots__ = ("route_selection", "rationale")
|
||||
ROUTE_SELECTION_FIELD_NUMBER: _ClassVar[int]
|
||||
RATIONALE_FIELD_NUMBER: _ClassVar[int]
|
||||
route_selection: _containers.RepeatedCompositeFieldContainer[RouteSelection]
|
||||
rationale: str
|
||||
def __init__(self, route_selection: _Optional[_Iterable[_Union[RouteSelection, _Mapping]]] = ..., rationale: _Optional[str] = ...) -> None: ...
|
||||
+28
-43
@@ -58,12 +58,6 @@ from omnigent.harness_aliases import canonicalize_harness
|
||||
from omnigent.inner import _proc
|
||||
from omnigent.inner.databricks_executor import _DatabricksBearerAuth, _read_databrickscfg
|
||||
from omnigent.native_coding_agents import native_coding_agent_for_wrapper_label
|
||||
from omnigent.process_logging import (
|
||||
PROCESS_LOG_FILE_ENV_VAR,
|
||||
child_logging_popen_kwargs,
|
||||
logs_root,
|
||||
open_process_log_file,
|
||||
)
|
||||
from omnigent.spec import load as load_spec
|
||||
from omnigent.spec._omnigent_compat import OMNIGENT_EXECUTOR_TYPE
|
||||
from omnigent.spec.parser import discover_host_skills
|
||||
@@ -3229,16 +3223,6 @@ def _apply_overrides_to_raw(raw: _YamlMapping, overrides: ChatOverrides) -> None
|
||||
executor_block["model"] = overrides.model
|
||||
if overrides.harness is not None:
|
||||
_apply_harness_override_to_executor(raw, executor_block, overrides.harness)
|
||||
# A harness-only override drops any prior model pin so the new
|
||||
# harness resolves its provider default — e.g. ``omnigent run
|
||||
# examples/polly --harness pi`` must not keep Polly's Claude-only
|
||||
# a Claude-only ``executor.model``. An explicit ``--model``
|
||||
# (applied above) wins and is left alone.
|
||||
if overrides.model is None:
|
||||
executor_block.pop("model", None)
|
||||
llm_block = raw.get("llm")
|
||||
if isinstance(llm_block, dict):
|
||||
llm_block.pop("model", None)
|
||||
# When neither harness nor model is declared — after overrides —
|
||||
# inject the ad-hoc default. Gated on harness absence so a YAML
|
||||
# like ``claude_code_agent.yaml`` (declares harness, no model)
|
||||
@@ -3436,7 +3420,7 @@ def _omnigent_log_dir() -> Path:
|
||||
|
||||
:returns: ``~/.omnigent/logs``, created if needed.
|
||||
"""
|
||||
log_dir = logs_root()
|
||||
log_dir = Path.home() / ".omnigent" / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
return log_dir
|
||||
|
||||
@@ -3505,7 +3489,11 @@ def _start_local_server(
|
||||
:returns: The server handle bundling the subprocess and
|
||||
the path to its captured stdout/stderr log file.
|
||||
"""
|
||||
log_path, log_fh = open_process_log_file("server", root=_omnigent_log_dir())
|
||||
log_dir = _omnigent_log_dir() / "server"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_fd, log_name = tempfile.mkstemp(prefix="server-", suffix=".log", dir=log_dir)
|
||||
log_path = Path(log_name)
|
||||
log_fh = os.fdopen(log_fd, "wb")
|
||||
if ephemeral:
|
||||
data_tmpdir = tempfile.mkdtemp(prefix="ap-chat-data-")
|
||||
db_path = Path(data_tmpdir) / "chat.db"
|
||||
@@ -3546,7 +3534,6 @@ def _start_local_server(
|
||||
child_env = {
|
||||
**os.environ,
|
||||
"OMNIGENT_RUNNER_TUNNEL_TOKEN": binding_token,
|
||||
PROCESS_LOG_FILE_ENV_VAR: str(log_path),
|
||||
# Single-user loopback runtime — see ensure_local_omnigent_server for why
|
||||
# this lets the host tunnel re-own this machine's host_id across an
|
||||
# auth-mode flip without weakening the deployed multi-user boundary.
|
||||
@@ -3579,30 +3566,28 @@ def _start_local_server(
|
||||
child_env["DATABRICKS_CONFIG_PROFILE"] = _spec.executor.profile
|
||||
|
||||
try:
|
||||
with child_logging_popen_kwargs(child_env) as logging_kwargs:
|
||||
server_proc = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"omnigent.cli",
|
||||
"server",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
"--database-uri",
|
||||
f"sqlite:///{db_path}",
|
||||
"--artifact-location",
|
||||
str(artifact_path),
|
||||
"--agent",
|
||||
str(agent_path),
|
||||
],
|
||||
env=child_env,
|
||||
stdout=log_fh,
|
||||
stderr=log_fh,
|
||||
**_proc.spawn_kwargs(),
|
||||
**logging_kwargs,
|
||||
)
|
||||
server_proc = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"omnigent.cli",
|
||||
"server",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
"--database-uri",
|
||||
f"sqlite:///{db_path}",
|
||||
"--artifact-location",
|
||||
str(artifact_path),
|
||||
"--agent",
|
||||
str(agent_path),
|
||||
],
|
||||
env=child_env,
|
||||
stdout=log_fh,
|
||||
stderr=log_fh,
|
||||
**_proc.spawn_kwargs(),
|
||||
)
|
||||
finally:
|
||||
log_fh.close()
|
||||
|
||||
|
||||
@@ -322,16 +322,6 @@ def build_native_claude_terminal_env(
|
||||
terminal_env.update(claude_config.env)
|
||||
terminal_env[_CLAUDE_CODE_ENABLE_TOOL_SEARCH_ENV] = "true"
|
||||
terminal_env[_CLAUDE_CODE_DISABLE_AGENT_VIEW_ENV] = "1"
|
||||
# On the apiKeyHelper path the credential reaches Claude Code via the
|
||||
# helper; a raw ANTHROPIC_API_KEY here re-triggers Claude Code's "Detected a
|
||||
# custom API key" menu, which hangs tmux delivery. Fail loud if one leaks.
|
||||
if claude_config is not None and claude_config.api_key_helper:
|
||||
if _ANTHROPIC_API_KEY_ENV in terminal_env:
|
||||
raise RuntimeError(
|
||||
"native-claude: apiKeyHelper is configured but the terminal env "
|
||||
f"carries a raw {_ANTHROPIC_API_KEY_ENV}; the credential must reach "
|
||||
"Claude Code via the helper, not the environment."
|
||||
)
|
||||
return terminal_env
|
||||
|
||||
|
||||
@@ -3671,21 +3661,13 @@ def _claude_transcript_record_from_session_item(
|
||||
if not isinstance(output, str):
|
||||
output = "" if output is None else json.dumps(output, separators=(",", ":"))
|
||||
record_type = "user"
|
||||
# Image (and other structured) tool results are persisted as a
|
||||
# stringified content-block array. Rehydrate them into real blocks
|
||||
# so ``claude --resume`` sends screenshots as images — not as ~250K
|
||||
# tokens of base64 text — and the model actually sees them again.
|
||||
content_blocks = _claude_tool_result_content_blocks(output)
|
||||
content: str | list[dict[str, Any]] = (
|
||||
content_blocks if content_blocks is not None else output
|
||||
)
|
||||
message = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": call_id,
|
||||
"content": content,
|
||||
"content": output,
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -3839,42 +3821,6 @@ def _json_safe_tool_use_result(output: str) -> str:
|
||||
return output
|
||||
|
||||
|
||||
def _claude_tool_result_content_blocks(output: str) -> list[dict[str, Any]] | None:
|
||||
"""
|
||||
Rehydrate a stringified content-block array into real blocks.
|
||||
|
||||
Tool results that return image content are persisted as a JSON *string*
|
||||
like ``'[{"type":"image","source":{...}}]'``. Passing that string
|
||||
straight into a ``tool_result`` content block makes ``claude --resume``
|
||||
send the base64 to the API as plain text — a single screenshot balloons
|
||||
to ~250K text tokens instead of the ~1.5K an image block costs, which is
|
||||
what pushes a resumed conversation over the context limit.
|
||||
|
||||
Only ``text`` and ``image`` blocks are rehydrated: those are the block
|
||||
types the API accepts inside a ``tool_result``. Anything else (plain
|
||||
text, or a JSON array of some other shape) stays a raw string so the
|
||||
resume request keeps sending exactly what it did before.
|
||||
|
||||
:param output: The persisted tool-result string, e.g.
|
||||
``'[{"type":"image","source":{"type":"base64","data":"..."}}]'``
|
||||
or plain text like ``"file written"``.
|
||||
:returns: A list of content blocks when *output* parses to a non-empty
|
||||
list of ``text``/``image`` block dicts; ``None`` otherwise, so the
|
||||
caller keeps the raw string as the block content.
|
||||
"""
|
||||
try:
|
||||
parsed = json.loads(output)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(parsed, list) or not parsed:
|
||||
return None
|
||||
if not all(
|
||||
isinstance(block, dict) and block.get("type") in ("text", "image") for block in parsed
|
||||
):
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def _preflight_local_tools(command: str) -> None:
|
||||
"""
|
||||
Verify local executables required by the native Claude wrapper.
|
||||
|
||||
@@ -124,10 +124,6 @@ _TMUX_SEND_TIMEOUT_S = 5.0
|
||||
# The glyph persists while Claude is busy responding, so its presence
|
||||
# means "input box mounted" (not "idle"), which is what injection needs.
|
||||
_CLAUDE_PROMPT_GLYPH = "❯"
|
||||
# Matches a selected numbered menu row (``❯ 2. No (recommended)``): the glyph
|
||||
# followed by a numbered choice, which the chat input never renders. Used to
|
||||
# exclude startup menus from the readiness scan (see ``_is_selected_menu_row``).
|
||||
_SELECTED_MENU_ROW_RE = re.compile(rf"{_CLAUDE_PROMPT_GLYPH}\s*\d+\.\s")
|
||||
# Box-drawing glyphs Claude Code's input-box frame is made of. A line of
|
||||
# these below ``❯`` marks the live input box (see ``_is_box_rule``),
|
||||
# distinguishing it from a bare prompt echoed into scrollback.
|
||||
@@ -2933,24 +2929,12 @@ def _claude_prompt_rendered(pane: str) -> bool:
|
||||
it's framed by a box rule — the ``────`` closing line the live input
|
||||
box always renders below ``❯`` but a bare echoed prompt never has.
|
||||
|
||||
A bare ``❯`` on a selected numbered menu row is not the chat input. A
|
||||
numbered line with an input-box rule below it still counts, however: the
|
||||
readiness gate runs before every injection, so a restored composer draft
|
||||
may legitimately begin with text such as ``2. buy milk``.
|
||||
|
||||
:param pane: Captured pane text from :func:`_capture_pane`.
|
||||
:returns: ``True`` when the input box appears mounted.
|
||||
"""
|
||||
non_empty = [line for line in pane.splitlines() if line.strip()]
|
||||
tail_start = max(0, len(non_empty) - _PROMPT_SCAN_TAIL_LINES)
|
||||
for idx in range(tail_start, len(non_empty)):
|
||||
line = non_empty[idx]
|
||||
if _CLAUDE_PROMPT_GLYPH not in line:
|
||||
continue
|
||||
if not _is_selected_menu_row(line) or any(
|
||||
_is_box_rule(rule) for rule in non_empty[idx + 1 :]
|
||||
):
|
||||
return True
|
||||
if any(_CLAUDE_PROMPT_GLYPH in line for line in non_empty[-_PROMPT_SCAN_TAIL_LINES:]):
|
||||
return True
|
||||
# Above that window, trust the glyph only when a box rule sits below
|
||||
# it — the live input box's closing frame, absent from scrollback.
|
||||
# The footer height scales with concurrent subagents (a fan-out of
|
||||
@@ -2965,23 +2949,6 @@ def _claude_prompt_rendered(pane: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _is_selected_menu_row(line: str) -> bool:
|
||||
"""
|
||||
Return whether a ``❯`` line is a selected numbered menu row.
|
||||
|
||||
Claude Code's startup menus (e.g. the "Detected a custom API key"
|
||||
confirmation) mark the highlighted choice with the same ``❯`` glyph the
|
||||
chat input uses (``❯ 2. No (recommended)``). The readiness scan must not
|
||||
treat such a row as the chat composer, or the first message gets typed
|
||||
into the menu. A chat prompt never renders a numbered choice after the
|
||||
glyph, so the ``<glyph> <digit>.`` shape distinguishes them.
|
||||
|
||||
:param line: A single pane line, e.g. ``"❯ 2. No (recommended)"``.
|
||||
:returns: ``True`` when the line is a selected numbered menu choice.
|
||||
"""
|
||||
return bool(_SELECTED_MENU_ROW_RE.match(line.strip()))
|
||||
|
||||
|
||||
def _is_box_rule(line: str) -> bool:
|
||||
"""
|
||||
Return whether a line is a TUI box-drawing horizontal rule.
|
||||
|
||||
@@ -119,23 +119,12 @@ def _state_dir_for_conversation_id(conversation_id: str) -> Path:
|
||||
every byte that lands in the path is hex, so the result is
|
||||
always a single child of the state root.
|
||||
|
||||
Sessions created before ids dropped the ``conv_`` prefix hashed the
|
||||
prefixed string, so their directories live under the legacy digest; when
|
||||
the bare-digest directory is absent, the legacy one is returned (never
|
||||
renamed — files inside may embed their own absolute path).
|
||||
|
||||
:param conversation_id: Omnigent conversation id, bare 32-char hex
|
||||
(a legacy ``conv_``-prefixed form is accepted and normalised).
|
||||
:param conversation_id: Omnigent conversation id, e.g.
|
||||
``"conv_abc123"``.
|
||||
:returns: Absolute directory path; not guaranteed to exist.
|
||||
"""
|
||||
bare = conversation_id.removeprefix("conv_")
|
||||
root = _claude_native_state_root()
|
||||
state_dir = root / hashlib.sha256(bare.encode("utf-8")).hexdigest()[:_ID_HASH_CHARS]
|
||||
if not state_dir.exists():
|
||||
legacy = root / hashlib.sha256(f"conv_{bare}".encode()).hexdigest()[:_ID_HASH_CHARS]
|
||||
if legacy.exists():
|
||||
return legacy
|
||||
return state_dir
|
||||
digest = hashlib.sha256(conversation_id.encode("utf-8")).hexdigest()[:_ID_HASH_CHARS]
|
||||
return _claude_native_state_root() / digest
|
||||
|
||||
|
||||
def write_launch_state(conversation_id: str, working_directory: str) -> None:
|
||||
|
||||
+135
-651
File diff suppressed because it is too large
Load Diff
+13
-28
@@ -2,7 +2,7 @@
|
||||
Always-on CLI diagnostics log.
|
||||
|
||||
Captures exceptions, warnings, and diagnostic info to a per-invocation
|
||||
log file under ``<data-dir>/logs/cli/cli-*.log``. Separate from the
|
||||
log file under ``~/.omnigent/logs/cli-*.log``. Separate from the
|
||||
``--log`` conversation JSON transcript and the ``--debug-events`` SSE
|
||||
tape — this layer is always on so crash context is available even when
|
||||
the user didn't know to enable debugging ahead of time.
|
||||
@@ -36,20 +36,14 @@ from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from omnigent.process_logging import (
|
||||
TerminalLogFormatter,
|
||||
effective_log_level,
|
||||
env_truthy,
|
||||
process_log_dir,
|
||||
terminal_supports_color,
|
||||
)
|
||||
from omnigent_ui_sdk import state_dir
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Destination subdirectory under ``<data-dir>/logs`` for CLI diagnostics.
|
||||
_LOG_DESTINATION = "cli"
|
||||
#: Subdirectory under :func:`state_dir` for CLI diagnostic logs.
|
||||
_LOGS_SUBDIR = "logs"
|
||||
|
||||
#: Maximum number of ``cli-*.log`` files kept before pruning.
|
||||
MAX_LOG_FILES = 20
|
||||
@@ -138,7 +132,7 @@ def _redact(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
class _RedactingFormatter(TerminalLogFormatter):
|
||||
class _RedactingFormatter(logging.Formatter):
|
||||
"""
|
||||
Formatter that scrubs obvious secrets from the *final* formatted
|
||||
output — after ``%``-interpolation of ``record.args`` and after
|
||||
@@ -230,12 +224,12 @@ def _log_dir() -> Path:
|
||||
"""
|
||||
Return the CLI diagnostics log directory.
|
||||
|
||||
Uses the shared Omnigent runtime data dir so ``OMNIGENT_DATA_DIR``
|
||||
isolates diagnostics with the DB, artifacts, and process logs.
|
||||
Uses :func:`omnigent_ui_sdk.state_dir` as the shared
|
||||
``~/.omnigent`` root so the path is defined in one place.
|
||||
|
||||
:returns: ``<data-dir>/logs/cli``.
|
||||
:returns: ``~/.omnigent/logs``.
|
||||
"""
|
||||
return process_log_dir(_LOG_DESTINATION)
|
||||
return Path(state_dir()) / _LOGS_SUBDIR
|
||||
|
||||
|
||||
def setup_cli_logging(argv: list[str]) -> CliLogContext:
|
||||
@@ -267,38 +261,29 @@ def setup_cli_logging(argv: list[str]) -> CliLogContext:
|
||||
log_path = log_dir / filename
|
||||
|
||||
# Rotating handler — caps a single invocation at MAX_LOG_BYTES.
|
||||
log_level = effective_log_level()
|
||||
handler = RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=MAX_LOG_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler.setLevel(log_level)
|
||||
# Best-effort 0600 permissions on the log file.
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(log_path, 0o600)
|
||||
|
||||
handler.setFormatter(
|
||||
_RedactingFormatter(
|
||||
use_colors=False,
|
||||
fmt="%(asctime)s %(levelname)-5s [%(name)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
)
|
||||
|
||||
stream_handler: logging.Handler | None = None
|
||||
if env_truthy(os.environ.get("OMNIGENT_LOG_TO_STDERR")) and sys.stderr.isatty():
|
||||
stream_handler = logging.StreamHandler(sys.stderr)
|
||||
stream_handler.setLevel(log_level)
|
||||
stream_handler.setFormatter(_RedactingFormatter(use_colors=terminal_supports_color()))
|
||||
|
||||
# Wire our two package hierarchies at the effective level so their records reach
|
||||
# Wire our two package hierarchies at INFO so their records reach
|
||||
# the file handler.
|
||||
for name in ("omnigent", "omnigent_ui_sdk"):
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(log_level)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.addHandler(handler)
|
||||
if stream_handler is not None:
|
||||
logger.addHandler(stream_handler)
|
||||
logger.propagate = False
|
||||
|
||||
# Suppress noisy third-party loggers that are commonly present.
|
||||
|
||||
@@ -61,23 +61,12 @@ def _state_dir_for_conversation_id(conversation_id: str) -> Path:
|
||||
Hashing the conversation id prevents path traversal if a server
|
||||
ever returned an attacker-controlled id such as ``"../etc"``.
|
||||
|
||||
Sessions created before ids dropped the ``conv_`` prefix hashed the
|
||||
prefixed string, so their directories live under the legacy digest; when
|
||||
the bare-digest directory is absent, the legacy one is returned (never
|
||||
renamed — files inside may embed their own absolute path).
|
||||
|
||||
:param conversation_id: Omnigent conversation id, bare 32-char hex
|
||||
(a legacy ``conv_``-prefixed form is accepted and normalised).
|
||||
:param conversation_id: Omnigent conversation id, e.g.
|
||||
``"conv_abc123"``.
|
||||
:returns: Absolute directory path; not guaranteed to exist.
|
||||
"""
|
||||
bare = conversation_id.removeprefix("conv_")
|
||||
root = _codex_native_state_root()
|
||||
state_dir = root / hashlib.sha256(bare.encode("utf-8")).hexdigest()[:_ID_HASH_CHARS]
|
||||
if not state_dir.exists():
|
||||
legacy = root / hashlib.sha256(f"conv_{bare}".encode()).hexdigest()[:_ID_HASH_CHARS]
|
||||
if legacy.exists():
|
||||
return legacy
|
||||
return state_dir
|
||||
digest = hashlib.sha256(conversation_id.encode("utf-8")).hexdigest()[:_ID_HASH_CHARS]
|
||||
return _codex_native_state_root() / digest
|
||||
|
||||
|
||||
def write_launch_state(conversation_id: str, working_directory: str) -> None:
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
from omnigent.db.db_models import (
|
||||
DEFAULT_WORKSPACE_ID,
|
||||
ConversationBase,
|
||||
OmnigentBase,
|
||||
Base,
|
||||
SqlAgent,
|
||||
SqlConversation,
|
||||
SqlConversationItem,
|
||||
@@ -16,8 +15,7 @@ from omnigent.db.db_models import (
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_WORKSPACE_ID",
|
||||
"ConversationBase",
|
||||
"OmnigentBase",
|
||||
"Base",
|
||||
"SqlAgent",
|
||||
"SqlConversation",
|
||||
"SqlConversationItem",
|
||||
|
||||
+183
-524
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from contextvars import ContextVar
|
||||
from typing import Any
|
||||
@@ -20,7 +19,6 @@ from sqlalchemy import (
|
||||
SmallInteger,
|
||||
String,
|
||||
Text,
|
||||
TypeDecorator,
|
||||
UniqueConstraint,
|
||||
false,
|
||||
text,
|
||||
@@ -37,159 +35,8 @@ from omnigent.db.compression import CompressedText
|
||||
_CKSUM32 = LargeBinary(32).with_variant(MySQLBinary(32), "mysql")
|
||||
|
||||
|
||||
# Hex length of a bare uuid4 id, the canonical Python-side form.
|
||||
_UUID_HEX_LEN = 32
|
||||
|
||||
# Prefixes ids carried before they became bare 32-char hex. ``uuid_to_bytes``
|
||||
# strips exactly these (so old URLs/clients keep resolving) and nothing else —
|
||||
# an unknown prefix fails loud rather than silently storing a wrong-typed id's
|
||||
# hex tail (e.g. a ``resp_``/``runner_token_`` value mis-passed to a uuid column).
|
||||
_LEGACY_ID_PREFIXES = frozenset(
|
||||
{
|
||||
"ag",
|
||||
"conv",
|
||||
"host",
|
||||
"pol",
|
||||
"file",
|
||||
"cmt",
|
||||
# conversation-item per-type prefixes
|
||||
"msg",
|
||||
"fc",
|
||||
"fco",
|
||||
"err",
|
||||
"rs",
|
||||
"cmp",
|
||||
"nt",
|
||||
"rse",
|
||||
"sc",
|
||||
"tc",
|
||||
"rd",
|
||||
# runner-internal conversation binding
|
||||
"agy_conv",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class InvalidUuidError(ValueError):
|
||||
"""An id string could not be normalised to a 32-char hex uuid.
|
||||
|
||||
Subclasses ``ValueError`` so existing ``except ValueError`` sites keep
|
||||
working. Surfaced (wrapped in ``sqlalchemy.exc.StatementError``) when a
|
||||
malformed id reaches a ``Uuid16`` column bind; the server maps it to a 404
|
||||
so a bad id in a URL is not-found rather than a 500.
|
||||
"""
|
||||
|
||||
|
||||
def uuid_to_bytes(value: str | uuid.UUID) -> bytes:
|
||||
"""Normalise an id to the 16 raw bytes stored in a ``Uuid16`` column.
|
||||
|
||||
Accepts, reducing them all to the same 16 bytes: a :class:`uuid.UUID`
|
||||
object; the bare 32-char hex form (what generators emit); the dashed
|
||||
canonical uuid (``str(uuid4())``); and a legacy id carrying one of the
|
||||
known :data:`_LEGACY_ID_PREFIXES` (``conv_<hex>``, ``ag_<hex>``, …) — so
|
||||
old bookmarked URLs, pasted ids, and pre-migration clients keep resolving.
|
||||
Anything else — a truncated id, non-hex text, an unknown prefix — fails
|
||||
loud rather than silently storing the wrong bytes.
|
||||
|
||||
:param value: A ``uuid.UUID``, or a 32-char hex uuid optionally dashed or
|
||||
legacy-prefixed.
|
||||
:returns: The 16-byte big-endian value.
|
||||
:raises InvalidUuidError: If *value* is not a 32-char hex uuid.
|
||||
"""
|
||||
if isinstance(value, uuid.UUID):
|
||||
return value.bytes
|
||||
normalized = value.replace("-", "")
|
||||
if "_" in normalized:
|
||||
prefix, _, tail = normalized.rpartition("_")
|
||||
if prefix in _LEGACY_ID_PREFIXES and len(tail) == _UUID_HEX_LEN:
|
||||
normalized = tail
|
||||
if len(normalized) != _UUID_HEX_LEN:
|
||||
raise InvalidUuidError(f"expected a 32-char hex uuid, got {value!r}")
|
||||
try:
|
||||
return bytes.fromhex(normalized)
|
||||
except ValueError as exc:
|
||||
raise InvalidUuidError(f"invalid hex uuid: {value!r}") from exc
|
||||
|
||||
|
||||
def normalize_uuid(value: str | None) -> str | None:
|
||||
"""Return the bare 32-char hex form of *value*, or *value* unchanged.
|
||||
|
||||
The forgiving companion to :func:`uuid_to_bytes` for **Python-side** id
|
||||
comparisons (e.g. a store's scope check against an ORM attribute, which
|
||||
always reads back bare hex). A legacy-prefixed or dashed input normalises
|
||||
to bare hex; a malformed input is returned as-is so the comparison simply
|
||||
mismatches — preserving the pre-migration "unknown id = not found"
|
||||
behaviour instead of raising. ``None`` passes through.
|
||||
|
||||
:param value: Any caller-supplied id string, or ``None``.
|
||||
:returns: The bare 32-char hex form, or *value* verbatim if not a uuid.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return uuid_to_bytes(value).hex()
|
||||
except InvalidUuidError:
|
||||
return value
|
||||
|
||||
|
||||
class Uuid16(TypeDecorator[str]):
|
||||
"""A uuid stored as 16 raw bytes, presented to Python as bare 32-char hex.
|
||||
|
||||
Our ids are opaque 128-bit uuid4s stored as raw bytes — ``BYTEA``
|
||||
(PostgreSQL), ``BLOB`` (SQLite / D1), fixed-length ``BINARY(16)`` (MySQL,
|
||||
where a BLOB is not indexable without a key-prefix length). The rest of
|
||||
the system keeps the readable bare 32-char hex form (entities, JSON
|
||||
blobs, URLs, the FTS mirror), so this type converts at the column
|
||||
boundary and nothing else has to change. Binds accept bare, dashed, or
|
||||
legacy-prefixed uuids; results always come back as bare lowercase hex.
|
||||
Result values guard the same driver variance ``CompressedText`` does:
|
||||
``bytes``, ``memoryview`` (some drivers), or ``str`` (already hex).
|
||||
"""
|
||||
|
||||
impl = LargeBinary(16)
|
||||
cache_ok = True
|
||||
|
||||
def load_dialect_impl(self, dialect: Any) -> Any:
|
||||
if dialect.name == "mysql":
|
||||
return dialect.type_descriptor(MySQLBinary(16))
|
||||
return dialect.type_descriptor(LargeBinary(16))
|
||||
|
||||
def process_bind_param(self, value: str | uuid.UUID | None, _dialect: object) -> bytes | None:
|
||||
if value is None:
|
||||
return None
|
||||
return uuid_to_bytes(value)
|
||||
|
||||
def process_result_value(
|
||||
self, value: bytes | memoryview | str | None, _dialect: object
|
||||
) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return bytes(value).hex()
|
||||
|
||||
|
||||
class OmnigentBase(DeclarativeBase):
|
||||
"""Declarative base for the Omnigent operational tables.
|
||||
|
||||
Covers agents, files, users, tokens, session permissions,
|
||||
conversation metadata, comments, policies, hosts, and daily costs.
|
||||
Grouped under their own ``metadata`` so schema creation and Alembic
|
||||
autogenerate can target the Omnigent side independently of the
|
||||
conversation tables.
|
||||
"""
|
||||
|
||||
|
||||
class ConversationBase(DeclarativeBase):
|
||||
"""Declarative base for the conversation tables.
|
||||
|
||||
Covers ``conversations``, ``conversation_items``, and
|
||||
``conversation_labels`` — the user-facing conversation surface
|
||||
(the Agent-Platform-side tables). Kept under their own ``metadata``
|
||||
so they can be created and, when ``conversation_storage_location``
|
||||
is configured, hosted on a separate physical database from the
|
||||
Omnigent tables.
|
||||
"""
|
||||
class Base(DeclarativeBase):
|
||||
"""Shared declarative base for all omnigent tables."""
|
||||
|
||||
|
||||
# Default workspace id stamped on every row and used as the leading
|
||||
@@ -243,7 +90,7 @@ POLICY_SCOPE_DEFAULT = "default"
|
||||
POLICY_SCOPE_SESSION = "session"
|
||||
|
||||
|
||||
class SqlAgent(OmnigentBase):
|
||||
class SqlAgent(Base):
|
||||
"""
|
||||
SQLAlchemy model for the ``agents`` table.
|
||||
|
||||
@@ -277,7 +124,7 @@ class SqlAgent(OmnigentBase):
|
||||
server_default="0",
|
||||
default=current_workspace_id,
|
||||
)
|
||||
id: Mapped[str] = mapped_column(Uuid16(), primary_key=True)
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
created_at: Mapped[int] = mapped_column(Integer)
|
||||
name: Mapped[str] = mapped_column(String(256))
|
||||
bundle_location: Mapped[str] = mapped_column(String(512))
|
||||
@@ -303,7 +150,7 @@ class SqlAgent(OmnigentBase):
|
||||
)
|
||||
|
||||
|
||||
class SqlFile(OmnigentBase):
|
||||
class SqlFile(Base):
|
||||
"""
|
||||
SQLAlchemy model for the ``files`` table.
|
||||
|
||||
@@ -329,12 +176,12 @@ class SqlFile(OmnigentBase):
|
||||
server_default="0",
|
||||
default=current_workspace_id,
|
||||
)
|
||||
id: Mapped[str] = mapped_column(Uuid16(), primary_key=True)
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
created_at: Mapped[int] = mapped_column(Integer)
|
||||
filename: Mapped[str] = mapped_column(String(512))
|
||||
bytes: Mapped[int] = mapped_column(Integer)
|
||||
content_type: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
session_id: Mapped[str | None] = mapped_column(Uuid16(), nullable=True)
|
||||
session_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_files_created_at", "workspace_id", "created_at", "id"),
|
||||
@@ -348,7 +195,7 @@ class SqlFile(OmnigentBase):
|
||||
)
|
||||
|
||||
|
||||
class SqlUser(OmnigentBase):
|
||||
class SqlUser(Base):
|
||||
"""
|
||||
SQLAlchemy model for the ``users`` table.
|
||||
|
||||
@@ -390,7 +237,7 @@ class SqlUser(OmnigentBase):
|
||||
last_login_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
|
||||
class SqlAccountToken(OmnigentBase):
|
||||
class SqlAccountToken(Base):
|
||||
"""
|
||||
SQLAlchemy model for the ``account_tokens`` table.
|
||||
|
||||
@@ -451,7 +298,7 @@ class SqlAccountToken(OmnigentBase):
|
||||
)
|
||||
|
||||
|
||||
class SqlSessionPermission(OmnigentBase):
|
||||
class SqlSessionPermission(Base):
|
||||
"""
|
||||
SQLAlchemy model for the ``session_permissions`` table.
|
||||
|
||||
@@ -487,7 +334,7 @@ class SqlSessionPermission(OmnigentBase):
|
||||
primary_key=True,
|
||||
)
|
||||
conversation_id: Mapped[str] = mapped_column(
|
||||
Uuid16(),
|
||||
String(64),
|
||||
primary_key=True,
|
||||
)
|
||||
level: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
@@ -505,120 +352,12 @@ class SqlSessionPermission(OmnigentBase):
|
||||
)
|
||||
|
||||
|
||||
class SqlConversationMetadata(OmnigentBase):
|
||||
"""
|
||||
SQLAlchemy model for the ``omnigent_conversation_metadata`` table.
|
||||
|
||||
Omnigent-side operational state for a conversation: runner/host
|
||||
bindings, native-session linkage, policy accumulators, and launch
|
||||
arguments. Paired 1-to-1 with :class:`SqlConversation` by
|
||||
``(workspace_id, id)``; rows are created and deleted together.
|
||||
"""
|
||||
|
||||
__tablename__ = "omnigent_conversation_metadata"
|
||||
|
||||
workspace_id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
default=current_workspace_id,
|
||||
)
|
||||
id: Mapped[str] = mapped_column(Uuid16(), primary_key=True)
|
||||
# Enum stored as a stable int code (CONVERSATION_KIND: default=1, sub_agent=2).
|
||||
kind: Mapped[int] = mapped_column(SmallInteger, default=1)
|
||||
runner_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# No FK: host records are managed outside this table.
|
||||
host_id: Mapped[str | None] = mapped_column(Uuid16(), nullable=True)
|
||||
sub_agent_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
external_session_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
session_state: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
|
||||
session_usage: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
|
||||
# JSON-encoded list of strings. NULL for non-native sessions.
|
||||
terminal_launch_args: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
|
||||
# Required when host_id is set; enforced by check constraint below.
|
||||
workspace: Mapped[str | None] = mapped_column(String(2048), nullable=True)
|
||||
git_branch: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("kind IN (1, 2)", name="ck_conversation_metadata_kind"),
|
||||
CheckConstraint(
|
||||
"host_id IS NULL OR workspace IS NOT NULL",
|
||||
name="ck_conversation_metadata_workspace_required_for_host",
|
||||
),
|
||||
# Supports list_conversations kind filter.
|
||||
Index("ix_conversation_metadata_kind", "workspace_id", "kind", "id"),
|
||||
# Supports list_conversations_by_runner_id and get_runner_ids.
|
||||
Index("ix_conversation_metadata_runner_id", "workspace_id", "runner_id", "id"),
|
||||
)
|
||||
|
||||
|
||||
class SqlAgentConfiguration(ConversationBase):
|
||||
"""
|
||||
SQLAlchemy model for the ``agent_configuration`` table.
|
||||
|
||||
The agent bound to a conversation and its per-session config
|
||||
overrides. Paired 1-to-1 with :class:`SqlConversation` by
|
||||
``(workspace_id, conversation_id)``; both tables live on the
|
||||
Conversation base, so the pair is created and deleted in one
|
||||
transaction.
|
||||
|
||||
:param conversation_id: Conversation this row belongs to, e.g.
|
||||
``"conv_e4f5a6b7..."``.
|
||||
:param agent_id: Agent bound to the conversation at creation
|
||||
time. ``None`` for conversations created without an agent
|
||||
binding.
|
||||
:param reasoning_effort: Per-session reasoning-effort hint.
|
||||
:param model_override: Per-session LLM model override.
|
||||
:param cost_control_mode_override: Per-session cost-control switch.
|
||||
:param harness_override: Per-session brain-harness override.
|
||||
"""
|
||||
|
||||
__tablename__ = "agent_configuration"
|
||||
|
||||
workspace_id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
default=current_workspace_id,
|
||||
)
|
||||
conversation_id: Mapped[str] = mapped_column(Uuid16(), primary_key=True)
|
||||
agent_id: Mapped[str | None] = mapped_column(Uuid16(), nullable=True)
|
||||
# Per-session reasoning-effort hint, e.g. "high". Nullable;
|
||||
# None means use the agent default.
|
||||
reasoning_effort: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# Per-session LLM model override, e.g. "claude-opus-4-7". Nullable;
|
||||
# None means use the agent default from the spec.
|
||||
model_override: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# Per-session cost-control switch: "on" | "off". Nullable; None
|
||||
# means use the spec default (see entities.Conversation).
|
||||
cost_control_mode_override: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
||||
# Per-session brain-harness override, e.g. "pi". Nullable; None
|
||||
# means use the spec's executor.config.harness (see entities.Conversation).
|
||||
harness_override: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
# Agent lookups: find the conversation(s) that own a given agent.
|
||||
# Covering: the reverse lookup and the list filters read only
|
||||
# conversation_id, so they resolve as index-only scans.
|
||||
Index(
|
||||
"ix_agent_configuration_agent_id",
|
||||
"workspace_id",
|
||||
"agent_id",
|
||||
"conversation_id",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SqlConversation(ConversationBase):
|
||||
class SqlConversation(Base):
|
||||
"""
|
||||
SQLAlchemy model for the ``conversations`` table.
|
||||
|
||||
Agent Platform (AP) fields for a conversation: identity, timestamps,
|
||||
title, hierarchy, and the next_position allocator. The agent binding
|
||||
and per-session overrides live in :class:`SqlAgentConfiguration`; Omnigent
|
||||
operational state in :class:`SqlConversationMetadata`.
|
||||
Each row represents a conversation thread that contains one or
|
||||
more conversation items.
|
||||
|
||||
:param id: Unique conversation identifier, e.g.
|
||||
``"conv_e4f5a6b7..."``.
|
||||
@@ -627,13 +366,66 @@ class SqlConversation(ConversationBase):
|
||||
:param updated_at: Unix epoch seconds when the conversation was
|
||||
last updated (item append, title change, etc.).
|
||||
:param title: Human-readable title; empty string when untitled.
|
||||
:param kind: Conversation type. ``"default"`` for user-initiated,
|
||||
``"sub_agent"`` for sub-agent execution conversations.
|
||||
:param parent_conversation_id: For Phase 4 named sub-agents,
|
||||
points at the parent conversation. ``None`` for top-level
|
||||
conversations.
|
||||
conversations. ``ON DELETE CASCADE`` so removing a parent
|
||||
cleans up the entire sub-tree.
|
||||
:param root_conversation_id: Id of the root (top-level)
|
||||
conversation in the spawn tree. Equal to ``id`` for
|
||||
top-level conversations.
|
||||
:param next_position: Monotonic allocator for the next item position.
|
||||
top-level conversations. Indexed so ``sys_session_get_history`` /
|
||||
``sys_session_close`` can verify that a target
|
||||
``conversation_id`` lives in the caller's tree in O(1) —
|
||||
any agent in the tree can address any other by
|
||||
``conversation_id``. ``ON DELETE CASCADE`` to keep it
|
||||
consistent with ``parent_conversation_id`` when a root is
|
||||
deleted.
|
||||
:param agent_id: Foreign key to the agent bound to this
|
||||
conversation at creation time. ``None`` for legacy
|
||||
conversations created without an agent binding (these are
|
||||
excluded from ``GET /v1/sessions`` results).
|
||||
:param runner_id: Runner the conversation is pinned to (hard
|
||||
affinity per ``designs/RUNNER.md`` §5). ``None`` until the
|
||||
first dispatch claims a runner; thereafter every subsequent
|
||||
dispatch routes to this runner while it is online (or fails
|
||||
with ``runner_unavailable`` if it isn't). No FK because
|
||||
runner records are not persisted in v1 — the registry is
|
||||
purely in-memory.
|
||||
:param external_session_id: Runtime-native session id this
|
||||
conversation wraps, e.g. Claude Code's session uuid for
|
||||
``omnigent claude`` sessions. ``None`` for regular
|
||||
AP-only conversations. Populated by the wrapper bridge
|
||||
from the underlying runtime and used by ``--resume`` to
|
||||
recover the external session's prior transcript. Generic
|
||||
across runtimes — at most one external session per
|
||||
conversation. No FK because the id is generated externally
|
||||
(by Claude Code, Codex, Pi, etc.) and is not tracked in
|
||||
any AP-side table.
|
||||
:param workspace: Absolute path on disk where the runner should
|
||||
start, e.g. ``"/Users/corey/universe/src/foo"``. Required
|
||||
when ``host_id`` is set (enforced by check constraint
|
||||
``ck_conversations_workspace_required_for_host``); optional
|
||||
for CLI-launched sessions that record their starting cwd
|
||||
for display. Stored as the canonicalized realpath returned
|
||||
by ``host.stat`` at session-create time; runtime symlinks
|
||||
are pre-resolved so the boundary check on the agent's
|
||||
``os_env.cwd`` cannot be smuggled past via a symlink.
|
||||
Immutable after creation —
|
||||
designs/SESSION_WORKSPACE_SELECTION.md. When a git worktree
|
||||
was created for the session, this is the worktree directory
|
||||
path rather than the picked source repo.
|
||||
:param git_branch: Git branch checked out in the session's
|
||||
worktree, e.g. ``"feature/login"``. Set only when the
|
||||
session was created with a server-created git worktree;
|
||||
``None`` otherwise. ``git_branch IS NOT NULL`` gates worktree
|
||||
cleanup on session delete. See
|
||||
designs/SESSION_GIT_WORKTREE.md.
|
||||
:param archived: Whether the session is archived. Archived
|
||||
sessions are hidden from the default ``GET /v1/sessions``
|
||||
listing (and the sidebar); the listing returns them only when
|
||||
``include_archived=True``. ``False`` for normal sessions.
|
||||
Reversible via ``PATCH /v1/sessions/{id}``.
|
||||
"""
|
||||
|
||||
__tablename__ = "conversations"
|
||||
@@ -646,43 +438,126 @@ class SqlConversation(ConversationBase):
|
||||
server_default="0",
|
||||
default=current_workspace_id,
|
||||
)
|
||||
id: Mapped[str] = mapped_column(Uuid16(), primary_key=True)
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
created_at: Mapped[int] = mapped_column(Integer)
|
||||
updated_at: Mapped[int] = mapped_column(Integer)
|
||||
title: Mapped[str] = mapped_column(String(768), nullable=False, server_default="")
|
||||
# Enum stored as a stable int code (see omnigent.db.enum_codecs
|
||||
# CONVERSATION_KIND: default=1, sub_agent=2). The store converts to/from
|
||||
# the string name at the row↔entity boundary.
|
||||
kind: Mapped[int] = mapped_column(SmallInteger, default=1)
|
||||
parent_conversation_id: Mapped[str | None] = mapped_column(
|
||||
Uuid16(),
|
||||
String(64),
|
||||
nullable=True,
|
||||
)
|
||||
root_conversation_id: Mapped[str] = mapped_column(
|
||||
Uuid16(),
|
||||
String(64),
|
||||
nullable=False,
|
||||
)
|
||||
agent_id: Mapped[str | None] = mapped_column(
|
||||
String(64),
|
||||
nullable=True,
|
||||
)
|
||||
runner_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# Host that launched (or should launch) the runner for this
|
||||
# session. Set when a session is created via the Web UI on a
|
||||
# specific host. No FK: host records are managed outside this
|
||||
# table; deletion is handled explicitly by the application.
|
||||
host_id: Mapped[str | None] = mapped_column(
|
||||
String(64),
|
||||
nullable=True,
|
||||
)
|
||||
# Per-session reasoning-effort hint, e.g. "high". Nullable;
|
||||
# None means use the agent default.
|
||||
reasoning_effort: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# Per-session LLM model override, e.g. "claude-opus-4-7". Nullable;
|
||||
# None means use the agent default from the spec.
|
||||
model_override: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# Per-session cost-control switch: "on" | "off". Nullable; None
|
||||
# means use the spec default (see entities.Conversation).
|
||||
cost_control_mode_override: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
||||
# Per-session brain-harness override, e.g. "pi". Nullable; None
|
||||
# means use the spec's executor.config.harness (see entities.Conversation).
|
||||
harness_override: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# Sub-agent type name within the parent's spec tree, e.g.
|
||||
# "summarizer". The runner uses this to load the sub-agent's
|
||||
# AgentSpec instead of the parent's. Replaces task.agent_name
|
||||
# from the removed task store. None for top-level sessions.
|
||||
sub_agent_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# Monotonic allocator for the next item position in this conversation.
|
||||
# append() reads and advances this instead of scanning
|
||||
# MAX(SqlConversationItem.position) on every write, making position
|
||||
# assignment O(1) and collision-free under the conversation lock. New rows
|
||||
# start at 0 (column default); NULL marks a row created before this column
|
||||
# existed, which append() backfills via a one-time scan on its next write.
|
||||
next_position: Mapped[int | None] = mapped_column(Integer, nullable=True, default=0)
|
||||
# Whether the session is archived (hidden from the default sidebar). Lives
|
||||
# here on the AP table so list_conversations can filter it inline alongside
|
||||
# the created_at/updated_at sort keys, instead of pre-fetching ids from the
|
||||
# Omnigent metadata DB.
|
||||
external_session_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# JSON-serialized mutable per-conversation key/value store
|
||||
# used by policy callables to accumulate state across turns.
|
||||
# NULL when no policy has written state yet; empty JSON object
|
||||
# "{}" is equivalent. Stored as Text (not a native JSON column)
|
||||
# for SQLite compatibility.
|
||||
session_state: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
|
||||
# JSON-serialized cumulative LLM token usage for policy
|
||||
# callables. Shape: {"input_tokens": N, "output_tokens": M,
|
||||
# "total_tokens": T, "cache_read_input_tokens": C1,
|
||||
# "cache_creation_input_tokens": C2, "total_cost_usd": X}.
|
||||
# NULL when no LLM calls have been recorded yet.
|
||||
session_usage: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
|
||||
# Pass-through CLI args for a native terminal wrapper (claude /
|
||||
# codex), JSON-encoded list of strings, e.g.
|
||||
# '["--dangerously-skip-permissions"]'. NULL for non-native
|
||||
# sessions. The runner reconstructs the terminal launch command
|
||||
# from these plus the harness binary; the command itself and all
|
||||
# bridge / AP-URL / auth wiring are runner-owned and never stored
|
||||
# here. A flat list (not a dict) is deliberate: there is no key for
|
||||
# a user to smuggle internal wiring through. See
|
||||
# designs/NATIVE_RUNNER_SERVER_LAUNCH.md.
|
||||
terminal_launch_args: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
|
||||
# Absolute path on the host where the runner cd's. Required
|
||||
# when host_id is set; CHECK constraint below. When a git worktree
|
||||
# was created for the session, this is the worktree directory path.
|
||||
workspace: Mapped[str | None] = mapped_column(String(2048), nullable=True)
|
||||
# Git branch checked out in the session's worktree, e.g.
|
||||
# "feature/login". Set only when the session was created with a
|
||||
# server-created git worktree; None otherwise. Gates worktree
|
||||
# cleanup on delete. See designs/SESSION_GIT_WORKTREE.md.
|
||||
git_branch: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
# Whether the session is archived (hidden from the default
|
||||
# /v1/sessions listing and the sidebar). False for normal
|
||||
# sessions; server_default false backfills existing rows on the
|
||||
# migration that adds this column. Low-cardinality, so no index —
|
||||
# the listing's accessible_by subquery is the selective filter.
|
||||
archived: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default=false()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("kind IN (1, 2)", name="ck_conversations_kind"),
|
||||
CheckConstraint(
|
||||
"host_id IS NULL OR workspace IS NOT NULL",
|
||||
name="ck_conversations_workspace_required_for_host",
|
||||
),
|
||||
Index("ix_conversations_created_at", "workspace_id", "created_at", "id"),
|
||||
Index("ix_conversations_updated_at", "workspace_id", "updated_at", "id"),
|
||||
# Default sidebar filters archived=false and sorts by updated_at DESC;
|
||||
# archived leads as an equality so the page walk stays index-only.
|
||||
Index("ix_conversations_archived_updated", "workspace_id", "archived", "updated_at", "id"),
|
||||
Index("ix_conversations_kind", "workspace_id", "kind", "id"),
|
||||
# Agent lookups: find the conversation(s) that own a given agent.
|
||||
Index("ix_conversations_agent_id", "workspace_id", "agent_id", "id"),
|
||||
Index(
|
||||
"ix_conversations_root_conversation_id",
|
||||
"workspace_id",
|
||||
"root_conversation_id",
|
||||
"id",
|
||||
),
|
||||
# Reconnect/relaunch reconciliation looks up a runner's session(s)
|
||||
# by runner_id (list_conversations_by_runner_id) on every runner
|
||||
# reconnect; index it to avoid a full scan.
|
||||
Index("ix_conversations_runner_id", "workspace_id", "runner_id", "id"),
|
||||
# Unique index on (parent_conversation_id, title) prevents two
|
||||
# same-named children under the same parent. NULLs are distinct in a
|
||||
# unique index, so top-level conversations (NULL parent) are exempt.
|
||||
# same-named children under the same parent (G36 race protection at
|
||||
# the DB layer). Top-level conversations (NULL parent) are exempt
|
||||
# automatically: NULLs are distinct in a unique index, so no WHERE
|
||||
# predicate is needed — keeping it a plain index MySQL can build.
|
||||
Index(
|
||||
"ix_conversations_parent_title_unique",
|
||||
"workspace_id",
|
||||
@@ -691,7 +566,10 @@ class SqlConversation(ConversationBase):
|
||||
unique=True,
|
||||
mysql_length={"title": 512},
|
||||
),
|
||||
# Composite index for child-session listing.
|
||||
# Composite index for child-session listing
|
||||
# (list_conversations(kind="sub_agent", parent_conversation_id=...)).
|
||||
# Non-unique, so no scoping predicate is required; it simply indexes
|
||||
# every parented row rather than only the sub-agent ones.
|
||||
Index(
|
||||
"idx_conversations_parent",
|
||||
"workspace_id",
|
||||
@@ -702,7 +580,7 @@ class SqlConversation(ConversationBase):
|
||||
)
|
||||
|
||||
|
||||
class SqlConversationItem(ConversationBase):
|
||||
class SqlConversationItem(Base):
|
||||
"""
|
||||
SQLAlchemy model for the ``conversation_items`` table.
|
||||
|
||||
@@ -744,10 +622,10 @@ class SqlConversationItem(ConversationBase):
|
||||
# conversation_id leads id in the PK so a conversation's items stay
|
||||
# contiguous for the per-conversation prefix scans that dominate reads.
|
||||
conversation_id: Mapped[str] = mapped_column(
|
||||
Uuid16(),
|
||||
String(64),
|
||||
primary_key=True,
|
||||
)
|
||||
id: Mapped[str] = mapped_column(Uuid16(), primary_key=True)
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
response_id: Mapped[str] = mapped_column(String(64))
|
||||
created_at: Mapped[int] = mapped_column(Integer)
|
||||
# Enum stored as a stable int code (see omnigent.db.enum_codecs
|
||||
@@ -780,18 +658,6 @@ class SqlConversationItem(ConversationBase):
|
||||
"response_id",
|
||||
"id",
|
||||
),
|
||||
# Latest-message previews scan one type per conversation ordered by
|
||||
# position DESC (list_latest_message_items_for_conversations). Ordering
|
||||
# type before position lets the scan seek to (workspace_id,
|
||||
# conversation_id, type) and walk position DESC directly, avoiding a
|
||||
# heap recheck on type — which no other index covers.
|
||||
Index(
|
||||
"ix_conversation_items_conv_type_position",
|
||||
"workspace_id",
|
||||
"conversation_id",
|
||||
"type",
|
||||
text("position DESC"),
|
||||
),
|
||||
CheckConstraint(
|
||||
"type IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)",
|
||||
name="ck_conversation_items_type",
|
||||
@@ -806,7 +672,7 @@ class SqlConversationItem(ConversationBase):
|
||||
LABEL_VALUE_MAX_LEN = 256
|
||||
|
||||
|
||||
class SqlConversationLabel(ConversationBase):
|
||||
class SqlConversationLabel(Base):
|
||||
"""
|
||||
SQLAlchemy model for the ``conversation_labels`` table.
|
||||
|
||||
@@ -847,7 +713,7 @@ class SqlConversationLabel(ConversationBase):
|
||||
default=current_workspace_id,
|
||||
)
|
||||
conversation_id: Mapped[str] = mapped_column(
|
||||
Uuid16(),
|
||||
String(64),
|
||||
primary_key=True,
|
||||
)
|
||||
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
@@ -855,7 +721,7 @@ class SqlConversationLabel(ConversationBase):
|
||||
updated_at: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
|
||||
class SqlComment(OmnigentBase):
|
||||
class SqlComment(Base):
|
||||
"""SQLAlchemy model for the ``comments`` table.
|
||||
|
||||
Stores per-review comments associated with a conversation.
|
||||
@@ -901,8 +767,8 @@ class SqlComment(OmnigentBase):
|
||||
server_default="0",
|
||||
default=current_workspace_id,
|
||||
)
|
||||
id: Mapped[str] = mapped_column(Uuid16(), primary_key=True)
|
||||
conversation_id: Mapped[str] = mapped_column(Uuid16())
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
conversation_id: Mapped[str] = mapped_column(String(64))
|
||||
path: Mapped[str] = mapped_column(String(4096))
|
||||
start_index: Mapped[int] = mapped_column(Integer)
|
||||
end_index: Mapped[int] = mapped_column(Integer)
|
||||
@@ -951,7 +817,7 @@ def _default_policy_name_cksum(context: Any) -> bytes:
|
||||
return policy_name_cksum(context.get_current_parameters()["name"])
|
||||
|
||||
|
||||
class SqlPolicy(OmnigentBase):
|
||||
class SqlPolicy(Base):
|
||||
"""
|
||||
SQLAlchemy model for the ``policies`` table.
|
||||
|
||||
@@ -1006,7 +872,7 @@ class SqlPolicy(OmnigentBase):
|
||||
server_default="0",
|
||||
default=current_workspace_id,
|
||||
)
|
||||
id: Mapped[str] = mapped_column(Uuid16(), primary_key=True)
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(256))
|
||||
# sha256(name) — the value the name-uniqueness indexes key on instead of
|
||||
# the wide name column. Stamped from `name` on INSERT via the column
|
||||
@@ -1014,7 +880,7 @@ class SqlPolicy(OmnigentBase):
|
||||
name_cksum: Mapped[bytes] = mapped_column(_CKSUM32, default=_default_policy_name_cksum)
|
||||
# Nullable: NULL for server-wide default policies.
|
||||
session_id: Mapped[str | None] = mapped_column(
|
||||
Uuid16(),
|
||||
String(64),
|
||||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[int] = mapped_column(Integer)
|
||||
@@ -1060,7 +926,7 @@ class SqlPolicy(OmnigentBase):
|
||||
)
|
||||
|
||||
|
||||
class SqlHost(OmnigentBase):
|
||||
class SqlHost(Base):
|
||||
"""
|
||||
SQLAlchemy model for the ``hosts`` table.
|
||||
|
||||
@@ -1121,7 +987,7 @@ class SqlHost(OmnigentBase):
|
||||
server_default="0",
|
||||
default=current_workspace_id,
|
||||
)
|
||||
host_id: Mapped[str] = mapped_column(Uuid16(), primary_key=True)
|
||||
host_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
owner: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
# Enum stored as a stable int code (see omnigent.db.enum_codecs
|
||||
@@ -1150,7 +1016,7 @@ class SqlHost(OmnigentBase):
|
||||
)
|
||||
|
||||
|
||||
class SqlUserDailyCost(OmnigentBase):
|
||||
class SqlUserDailyCost(Base):
|
||||
"""
|
||||
SQLAlchemy model for the ``user_daily_cost`` table.
|
||||
|
||||
@@ -1202,210 +1068,3 @@ class SqlUserDailyCost(OmnigentBase):
|
||||
cost_usd: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
ask_approved_usd: Mapped[float] = mapped_column(Float, nullable=False, server_default="0")
|
||||
updated_at: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
|
||||
class SqlScheduledTask(OmnigentBase):
|
||||
"""
|
||||
SQLAlchemy model for the ``scheduled_tasks`` table.
|
||||
|
||||
A scheduled task is a saved, scheduled instruction that fires an agent
|
||||
session on a recurring cron schedule (``cron_expression``).
|
||||
|
||||
:param id: UUID primary key stored as 16 raw bytes (see :class:`Uuid16`),
|
||||
surfaced as a bare 32-char hex string (no dashes).
|
||||
:param name: Human-readable task name, e.g. ``"nightly triage"``.
|
||||
:param prompt: The instruction dispatched to the agent on each firing.
|
||||
:param cron_expression: The required cron string for the recurring trigger,
|
||||
e.g. ``"0 9 * * *"``.
|
||||
:param owner_user_id: User the spawned session's ``LEVEL_OWNER`` grant is
|
||||
written for — who the run belongs to, e.g. ``"alice@example.com"``.
|
||||
``None`` in single-user / OSS mode; the fire path resolves it to the
|
||||
reserved ``"local"`` user.
|
||||
:param agent_id: The agent bound to this task (relates to
|
||||
``agents.id``). Cascade cleanup on agent deletion is application-owned
|
||||
— there is no DB-level foreign key (schema Rule R032).
|
||||
:param model_override: Per-task LLM model override, e.g.
|
||||
``"claude-opus-4-7"``. ``None`` means use the agent default.
|
||||
:param reasoning_effort: Per-task reasoning-effort hint, e.g. ``"high"``.
|
||||
``None`` means use the agent default.
|
||||
:param workspace: Absolute path on disk where a fired session's runner
|
||||
should start (the source repo / working dir). ``None`` when unset.
|
||||
:param base_branch: Git base ref a firing branches FROM when it creates a
|
||||
worktree at fire time (mirrors session-create's ``git.base_branch``
|
||||
input). Pairs with ``workspace``:
|
||||
``workspace`` is where, ``base_branch`` is what to branch from. ``None``
|
||||
when unset. The per-run *output* branch is not stored on the definition.
|
||||
:param execution_target: Where a firing runs —
|
||||
``connected_host``/``managed_sandbox``. ``connected_host`` resolves the
|
||||
owner's live host at fire time (see ``host_id``); ``managed_sandbox``
|
||||
provisions/adopts a sandbox at fire time. Stored as a stable int code
|
||||
(see omnigent.db.enum_codecs SCHEDULED_TASK_EXECUTION_TARGET); the store
|
||||
converts to/from the string name at the row↔entity boundary. Defaults to
|
||||
``connected_host``.
|
||||
:param host_id: For ``execution_target=connected_host``, the specific host
|
||||
to run on (relates to ``hosts.host_id``; no DB foreign key, Rule R032).
|
||||
``None`` means "the owner's freshest online host". Always ``None`` for
|
||||
``managed_sandbox`` (the sandbox is provisioned/adopted under a
|
||||
deterministic id at fire time, so there is nothing to pin).
|
||||
:param timezone: IANA timezone the trigger is evaluated in, e.g.
|
||||
``"America/Los_Angeles"``.
|
||||
:param state: Lifecycle state — ``active``/``paused``/``deleted``.
|
||||
The scheduler only dispatches ``active`` tasks.
|
||||
Stored as a stable int code (see omnigent.db.enum_codecs
|
||||
SCHEDULED_TASK_STATE); the store converts to/from the string name at the
|
||||
row↔entity boundary. Defaults to ``active``.
|
||||
:param last_run_at: Unix epoch seconds of the most recent firing, or
|
||||
``None`` if it has never fired.
|
||||
:param last_run_conversation_id: The conversation created by the most recent
|
||||
firing (relates to ``conversations.id``). ``None`` if never fired or the
|
||||
referenced conversation was deleted (application-owned SET-NULL cleanup;
|
||||
no DB foreign key).
|
||||
:param created_at: Unix epoch seconds at row creation.
|
||||
:param updated_at: Unix epoch seconds of the last write, or ``None`` if the
|
||||
row has never been updated.
|
||||
"""
|
||||
|
||||
__tablename__ = "scheduled_tasks"
|
||||
|
||||
# Tenant partition key: Databricks workspace id owning this row (0 = default). Part of the PK.
|
||||
workspace_id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
default=current_workspace_id,
|
||||
)
|
||||
id: Mapped[str] = mapped_column(Uuid16, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
# Opaque free text, never SQL-queried — stored compressed (CompressedText).
|
||||
prompt: Mapped[str] = mapped_column(CompressedText, nullable=False)
|
||||
# e.g. "0 9 * * *"
|
||||
cron_expression: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
# Session-owner identity: the spawned run's LEVEL_OWNER grant is written
|
||||
# for this user. Nullable — None in single-user/OSS mode (the fire path
|
||||
# resolves null to the reserved "local" user). String(128) to match
|
||||
# session_permissions.user_id (the column the LEVEL_OWNER grant is
|
||||
# written into) and every other user-identity column in this schema.
|
||||
owner_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# Relates to agents.id. No DB foreign key (Rule R032); cascade is app-owned.
|
||||
agent_id: Mapped[str] = mapped_column(Uuid16, nullable=False)
|
||||
# Per-task overrides — None means fall back to the agent default. Widths
|
||||
# mirror the matching conversations.* override columns.
|
||||
model_override: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
reasoning_effort: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
workspace: Mapped[str | None] = mapped_column(String(2048), nullable=True)
|
||||
# Git base ref a firing branches from when it creates a worktree at fire
|
||||
# time (mirrors session-create's git.base_branch input). None when unset.
|
||||
base_branch: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
# Where a firing runs, as a stable int code (see omnigent.db.enum_codecs
|
||||
# SCHEDULED_TASK_EXECUTION_TARGET: connected_host=1, managed_sandbox=2).
|
||||
# connected_host → resolve the owner's live host at fire time (see host_id);
|
||||
# managed_sandbox → provision/adopt a sandbox at fire time. Defaults to
|
||||
# connected_host so existing rows keep the V1 behavior. The store converts
|
||||
# to/from the string name at the row↔entity boundary.
|
||||
execution_target: Mapped[int] = mapped_column(SmallInteger, nullable=False, server_default="1")
|
||||
# For execution_target=connected_host: the specific host to run on (relates
|
||||
# to hosts.host_id; No DB foreign key, Rule R032). None = "the owner's
|
||||
# freshest online host, whichever". Always None for managed_sandbox (the
|
||||
# sandbox is provisioned/adopted under a deterministic id at fire time, so
|
||||
# there is nothing to pin here).
|
||||
host_id: Mapped[str | None] = mapped_column(Uuid16, nullable=True)
|
||||
timezone: Mapped[str] = mapped_column(String(64), nullable=False, server_default="UTC")
|
||||
# Enum stored as a stable int code (see omnigent.db.enum_codecs
|
||||
# SCHEDULED_TASK_STATE: active=1, paused=2, deleted=3). The
|
||||
# store converts to/from the string name at the row↔entity boundary.
|
||||
state: Mapped[int] = mapped_column(SmallInteger, nullable=False, server_default="1")
|
||||
last_run_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Relates to conversations.id. No DB foreign key (Rule R032); the
|
||||
# application nulls this out when the referenced conversation is deleted.
|
||||
last_run_conversation_id: Mapped[str | None] = mapped_column(Uuid16, nullable=True)
|
||||
created_at: Mapped[int] = mapped_column(Integer)
|
||||
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("state IN (1, 2, 3)", name="ck_scheduled_tasks_state"),
|
||||
CheckConstraint("execution_target IN (1, 2)", name="ck_scheduled_tasks_execution_target"),
|
||||
Index("ix_scheduled_tasks_created_at", "workspace_id", "created_at", "id"),
|
||||
Index("ix_scheduled_tasks_owner_user_id", "workspace_id", "owner_user_id", "id"),
|
||||
# Covers the scheduler's read path:
|
||||
# WHERE workspace_id + state ORDER BY created_at, id.
|
||||
Index("ix_scheduled_tasks_state", "workspace_id", "state", "created_at", "id"),
|
||||
)
|
||||
|
||||
|
||||
class SqlScheduledTaskRun(OmnigentBase):
|
||||
"""
|
||||
SQLAlchemy model for the ``scheduled_task_runs`` table.
|
||||
|
||||
One row per firing of a scheduled task — the run history. Recorded and
|
||||
advanced by the scheduler as a firing moves through its lifecycle.
|
||||
|
||||
:param id: UUID primary key stored as 16 raw bytes (see :class:`Uuid16`),
|
||||
surfaced as a bare 32-char hex string (no dashes).
|
||||
:param scheduled_task_id: The task this run belongs to (relates to
|
||||
``scheduled_tasks.id``; also a :class:`Uuid16`). Indexed for per-task
|
||||
history listing. Cascade cleanup on task deletion is application-owned —
|
||||
no DB foreign key (Rule R032).
|
||||
:param conversation_id: The conversation created by this firing (relates to
|
||||
``conversations.id``). ``None`` before dispatch, or after the referenced
|
||||
conversation is deleted (application-owned SET-NULL; no DB foreign key).
|
||||
:param status: Lifecycle state —
|
||||
``scheduled``/``running``/``succeeded``/``failed``/``skipped``. Stored
|
||||
as a stable int code (see omnigent.db.enum_codecs
|
||||
SCHEDULED_TASK_RUN_STATUS); the store converts to/from the string name
|
||||
at the row↔entity boundary.
|
||||
:param scheduled_at: Unix epoch seconds the firing was scheduled for.
|
||||
:param fired_at: Unix epoch seconds dispatch actually began, or ``None`` if
|
||||
it has not fired yet.
|
||||
:param finished_at: Unix epoch seconds the run reached a terminal state, or
|
||||
``None`` if still pending/running.
|
||||
:param error: Failure detail when ``status = 'failed'``; ``None`` otherwise.
|
||||
:param error_code: Short failure classification (e.g. ``"timeout"``,
|
||||
``"rate_limited"``) for future retryable-vs-terminal retry logic;
|
||||
``None`` unless ``status = 'failed'``.
|
||||
"""
|
||||
|
||||
__tablename__ = "scheduled_task_runs"
|
||||
|
||||
# Tenant partition key: Databricks workspace id owning this row (0 = default). Part of the PK.
|
||||
workspace_id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
default=current_workspace_id,
|
||||
)
|
||||
id: Mapped[str] = mapped_column(Uuid16, primary_key=True)
|
||||
# Relates to scheduled_tasks.id. No DB foreign key (Rule R032); cascade is
|
||||
# app-owned.
|
||||
scheduled_task_id: Mapped[str] = mapped_column(Uuid16, nullable=False)
|
||||
# Relates to conversations.id. No DB foreign key; app nulls on delete.
|
||||
conversation_id: Mapped[str | None] = mapped_column(Uuid16, nullable=True)
|
||||
# Enum stored as a stable int code (see omnigent.db.enum_codecs
|
||||
# SCHEDULED_TASK_RUN_STATUS: scheduled=1, running=2, succeeded=3, failed=4,
|
||||
# skipped=5). The store converts to/from the string name at the
|
||||
# row↔entity boundary.
|
||||
status: Mapped[int] = mapped_column(SmallInteger)
|
||||
scheduled_at: Mapped[int] = mapped_column(Integer)
|
||||
fired_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
finished_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Opaque free-text error blob, never SQL-queried — stored compressed.
|
||||
error: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
|
||||
# Short, queryable failure classification token (e.g. "timeout",
|
||||
# "rate_limited") for future retry logic. Bounded plain string, not a blob;
|
||||
# no CHECK constraint (no code taxonomy defined yet).
|
||||
error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN (1, 2, 3, 4, 5)",
|
||||
name="ck_scheduled_task_runs_status",
|
||||
),
|
||||
Index(
|
||||
"ix_scheduled_task_runs_scheduled_task_id",
|
||||
"workspace_id",
|
||||
"scheduled_task_id",
|
||||
"scheduled_at",
|
||||
"id",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
Several low-cardinality closed-set columns (``conversations.kind``,
|
||||
``conversation_items.type``/``status``, ``comments.status``,
|
||||
``account_tokens.kind``, ``policies.type``, ``policies.scope``,
|
||||
``hosts.status``, ``agents.kind``, ``scheduled_tasks.state``,
|
||||
``scheduled_tasks.execution_target``,
|
||||
``scheduled_task_runs.status``) are stored as
|
||||
integer codes rather
|
||||
``hosts.status``, ``agents.kind``) are stored as integer codes rather
|
||||
than their string names — smaller rows and a tighter ``CHECK`` than a
|
||||
free ``VARCHAR``. The string names remain the
|
||||
contract for entities, the HTTP API, the web client, and the SDKs; the
|
||||
@@ -89,25 +86,6 @@ POLICY_SCOPE: dict[str, int] = {
|
||||
"session": 2,
|
||||
}
|
||||
|
||||
SCHEDULED_TASK_STATE: dict[str, int] = {
|
||||
"active": 1,
|
||||
"paused": 2,
|
||||
"deleted": 3,
|
||||
}
|
||||
|
||||
SCHEDULED_TASK_EXECUTION_TARGET: dict[str, int] = {
|
||||
"connected_host": 1,
|
||||
"managed_sandbox": 2,
|
||||
}
|
||||
|
||||
SCHEDULED_TASK_RUN_STATUS: dict[str, int] = {
|
||||
"scheduled": 1,
|
||||
"running": 2,
|
||||
"succeeded": 3,
|
||||
"failed": 4,
|
||||
"skipped": 5,
|
||||
}
|
||||
|
||||
|
||||
def _assert_item_type_codes_cover_data_classes() -> None:
|
||||
"""
|
||||
@@ -269,33 +247,3 @@ def encode_policy_scope(name: str) -> int:
|
||||
def decode_policy_scope(code: int) -> str:
|
||||
"""Decode a ``policies.scope`` int code to its name."""
|
||||
return _decode(POLICY_SCOPE, code, field="policies.scope")
|
||||
|
||||
|
||||
def encode_scheduled_task_state(name: str) -> int:
|
||||
"""Encode a ``scheduled_tasks.state`` name to its int code."""
|
||||
return _encode(SCHEDULED_TASK_STATE, name, field="scheduled_tasks.state")
|
||||
|
||||
|
||||
def decode_scheduled_task_state(code: int) -> str:
|
||||
"""Decode a ``scheduled_tasks.state`` int code to its name."""
|
||||
return _decode(SCHEDULED_TASK_STATE, code, field="scheduled_tasks.state")
|
||||
|
||||
|
||||
def encode_scheduled_task_execution_target(name: str) -> int:
|
||||
"""Encode a ``scheduled_tasks.execution_target`` name to its int code."""
|
||||
return _encode(SCHEDULED_TASK_EXECUTION_TARGET, name, field="scheduled_tasks.execution_target")
|
||||
|
||||
|
||||
def decode_scheduled_task_execution_target(code: int) -> str:
|
||||
"""Decode a ``scheduled_tasks.execution_target`` int code to its name."""
|
||||
return _decode(SCHEDULED_TASK_EXECUTION_TARGET, code, field="scheduled_tasks.execution_target")
|
||||
|
||||
|
||||
def encode_scheduled_task_run_status(name: str) -> int:
|
||||
"""Encode a ``scheduled_task_runs.status`` name to its int code."""
|
||||
return _encode(SCHEDULED_TASK_RUN_STATUS, name, field="scheduled_task_runs.status")
|
||||
|
||||
|
||||
def decode_scheduled_task_run_status(code: int) -> str:
|
||||
"""Decode a ``scheduled_task_runs.status`` int code to its name."""
|
||||
return _decode(SCHEDULED_TASK_RUN_STATUS, code, field="scheduled_task_runs.status")
|
||||
|
||||
@@ -8,7 +8,7 @@ from logging.config import fileConfig
|
||||
from alembic import context
|
||||
from sqlalchemy import Connection, engine_from_config, pool
|
||||
|
||||
from omnigent.db import ConversationBase, OmnigentBase
|
||||
from omnigent.db import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
@@ -31,9 +31,7 @@ if config.config_file_name is not None:
|
||||
if not _logging.getLogger().isEnabledFor(_logging.DEBUG):
|
||||
_logging.getLogger("alembic").setLevel(_logging.WARNING)
|
||||
|
||||
# Both bases share one physical DB and one migration lineage; autogenerate
|
||||
# diffs the union of their metadata so neither side's tables look "extra".
|
||||
target_metadata = [OmnigentBase.metadata, ConversationBase.metadata]
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# Allow overriding the DB URL via environment variable.
|
||||
db_url = os.environ.get("OMNIGENT_DB_URL")
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"""move archived column from metadata back to conversations
|
||||
|
||||
Revision ID: 9d820f91deef
|
||||
Revises: cc3d4e5f6a7b
|
||||
Create Date: 2026-07-14 00:00:00.000000
|
||||
|
||||
The conversations split (``aa1b2c3d4e5f``) moved ``archived`` onto
|
||||
``omnigent_conversation_metadata``. That forced ``list_conversations`` to
|
||||
pre-fetch every non-archived conversation id from the Omnigent DB and filter
|
||||
the AP query with a giant ``IN (...)``, because the sort keys
|
||||
(``created_at``/``updated_at``) stayed on ``conversations`` while the filter
|
||||
moved to the other logical DB.
|
||||
|
||||
This migration moves ``archived`` back onto ``conversations`` so the AP query
|
||||
can filter it inline next to the sort keys. It adds the column, backfills from
|
||||
``omnigent_conversation_metadata`` via a portable correlated subquery, drops
|
||||
it from the metadata table, and adds a composite index supporting the default
|
||||
sidebar (``archived=false ORDER BY updated_at DESC``). ``kind`` intentionally
|
||||
stays on the metadata table — the list filter now derives it from
|
||||
``parent_conversation_id`` instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "9d820f91deef"
|
||||
down_revision: str | None = "cc3d4e5f6a7b"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""
|
||||
Add ``conversations.archived``, backfill it from the metadata table,
|
||||
then drop it from ``omnigent_conversation_metadata``.
|
||||
|
||||
``server_default=sa.false()`` backfills existing rows for the NOT NULL
|
||||
add; the subsequent UPDATE overwrites them with the real value copied
|
||||
from metadata. Batch mode is used for the column add/drop for SQLite
|
||||
compatibility; the backfill uses a correlated subquery so it runs on
|
||||
SQLite, MySQL, and PostgreSQL alike (``UPDATE … FROM`` is
|
||||
PostgreSQL-only).
|
||||
"""
|
||||
with op.batch_alter_table("conversations") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"archived",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
)
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE conversations
|
||||
SET archived = COALESCE(
|
||||
(SELECT m.archived
|
||||
FROM omnigent_conversation_metadata m
|
||||
WHERE m.workspace_id = conversations.workspace_id
|
||||
AND m.id = conversations.id),
|
||||
FALSE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Default sidebar: archived=false ORDER BY updated_at DESC. archived leads
|
||||
# as an equality so the page walk stays index-only.
|
||||
op.create_index(
|
||||
"ix_conversations_archived_updated",
|
||||
"conversations",
|
||||
["workspace_id", "archived", "updated_at", "id"],
|
||||
)
|
||||
|
||||
with op.batch_alter_table("omnigent_conversation_metadata") as batch_op:
|
||||
batch_op.drop_column("archived")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""
|
||||
Reverse the move: re-add ``archived`` to the metadata table, backfill it
|
||||
from ``conversations``, drop the sidebar index, and drop the column from
|
||||
``conversations``.
|
||||
"""
|
||||
with op.batch_alter_table("omnigent_conversation_metadata") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"archived",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
)
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE omnigent_conversation_metadata
|
||||
SET archived = COALESCE(
|
||||
(SELECT c.archived
|
||||
FROM conversations c
|
||||
WHERE c.workspace_id = omnigent_conversation_metadata.workspace_id
|
||||
AND c.id = omnigent_conversation_metadata.id),
|
||||
FALSE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
op.drop_index("ix_conversations_archived_updated", table_name="conversations")
|
||||
|
||||
with op.batch_alter_table("conversations") as batch_op:
|
||||
batch_op.drop_column("archived")
|
||||
@@ -1,199 +0,0 @@
|
||||
"""Split conversations table into conversations + omnigent_conversation_metadata.
|
||||
|
||||
Revision ID: aa1b2c3d4e5f
|
||||
Revises: z5a2b3c4d5e6
|
||||
Create Date: 2026-07-10 00:00:00.000000
|
||||
|
||||
Splits Omnigent operational metadata out of the ``conversations`` table into a
|
||||
new ``omnigent_conversation_metadata`` table (1-to-1 paired by
|
||||
``(workspace_id, id)``). The columns moved are: ``kind``, ``runner_id``,
|
||||
``host_id``, ``sub_agent_name``, ``external_session_id``, ``session_state``,
|
||||
``session_usage``, ``terminal_launch_args``, ``workspace``, ``git_branch``,
|
||||
``archived``. The ``conversations`` table is left with only AP-side fields.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "aa1b2c3d4e5f"
|
||||
down_revision: str | None = "z5a2b3c4d5e6"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Create the new omnigent_conversation_metadata table.
|
||||
op.create_table(
|
||||
"omnigent_conversation_metadata",
|
||||
sa.Column(
|
||||
"workspace_id",
|
||||
sa.BigInteger(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.Column("id", sa.String(64), nullable=False),
|
||||
sa.Column("kind", sa.SmallInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("runner_id", sa.String(64), nullable=True),
|
||||
sa.Column("host_id", sa.String(64), nullable=True),
|
||||
sa.Column("sub_agent_name", sa.String(128), nullable=True),
|
||||
sa.Column("external_session_id", sa.String(128), nullable=True),
|
||||
sa.Column("session_state", sa.LargeBinary(), nullable=True),
|
||||
sa.Column("session_usage", sa.LargeBinary(), nullable=True),
|
||||
sa.Column("terminal_launch_args", sa.LargeBinary(), nullable=True),
|
||||
sa.Column("workspace", sa.String(2048), nullable=True),
|
||||
sa.Column("git_branch", sa.String(255), nullable=True),
|
||||
sa.Column(
|
||||
"archived",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
sa.PrimaryKeyConstraint("workspace_id", "id"),
|
||||
sa.CheckConstraint("kind IN (1, 2)", name="ck_conversation_metadata_kind"),
|
||||
sa.CheckConstraint(
|
||||
"host_id IS NULL OR workspace IS NOT NULL",
|
||||
name="ck_conversation_metadata_workspace_required_for_host",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_conversation_metadata_kind",
|
||||
"omnigent_conversation_metadata",
|
||||
["workspace_id", "kind", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_conversation_metadata_runner_id",
|
||||
"omnigent_conversation_metadata",
|
||||
["workspace_id", "runner_id", "id"],
|
||||
)
|
||||
|
||||
# 2. Copy data from conversations into the new table.
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO omnigent_conversation_metadata
|
||||
(workspace_id, id, kind, runner_id, host_id, sub_agent_name,
|
||||
external_session_id, session_state, session_usage,
|
||||
terminal_launch_args, workspace, git_branch, archived)
|
||||
SELECT workspace_id, id, kind, runner_id, host_id, sub_agent_name,
|
||||
external_session_id, session_state, session_usage,
|
||||
terminal_launch_args, workspace, git_branch, archived
|
||||
FROM conversations
|
||||
"""
|
||||
)
|
||||
|
||||
# 3. Drop indexes on conversations that covered metadata columns.
|
||||
op.drop_index("ix_conversations_kind", table_name="conversations")
|
||||
op.drop_index("ix_conversations_runner_id", table_name="conversations")
|
||||
|
||||
# 4. Drop check constraints on conversations that covered metadata columns.
|
||||
# Use batch_alter_table for SQLite compatibility.
|
||||
with op.batch_alter_table("conversations") as batch_op:
|
||||
batch_op.drop_constraint("ck_conversations_kind", type_="check")
|
||||
batch_op.drop_constraint("ck_conversations_workspace_required_for_host", type_="check")
|
||||
# 5. Drop the metadata columns from conversations.
|
||||
batch_op.drop_column("kind")
|
||||
batch_op.drop_column("runner_id")
|
||||
batch_op.drop_column("host_id")
|
||||
batch_op.drop_column("sub_agent_name")
|
||||
batch_op.drop_column("external_session_id")
|
||||
batch_op.drop_column("session_state")
|
||||
batch_op.drop_column("session_usage")
|
||||
batch_op.drop_column("terminal_launch_args")
|
||||
batch_op.drop_column("workspace")
|
||||
batch_op.drop_column("git_branch")
|
||||
batch_op.drop_column("archived")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 1. Re-add the metadata columns to conversations.
|
||||
with op.batch_alter_table("conversations") as batch_op:
|
||||
batch_op.add_column(sa.Column("kind", sa.SmallInteger(), nullable=True))
|
||||
batch_op.add_column(sa.Column("runner_id", sa.String(64), nullable=True))
|
||||
batch_op.add_column(sa.Column("host_id", sa.String(64), nullable=True))
|
||||
batch_op.add_column(sa.Column("sub_agent_name", sa.String(128), nullable=True))
|
||||
batch_op.add_column(sa.Column("external_session_id", sa.String(128), nullable=True))
|
||||
batch_op.add_column(sa.Column("session_state", sa.LargeBinary(), nullable=True))
|
||||
batch_op.add_column(sa.Column("session_usage", sa.LargeBinary(), nullable=True))
|
||||
batch_op.add_column(sa.Column("terminal_launch_args", sa.LargeBinary(), nullable=True))
|
||||
batch_op.add_column(sa.Column("workspace", sa.String(2048), nullable=True))
|
||||
batch_op.add_column(sa.Column("git_branch", sa.String(255), nullable=True))
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"archived",
|
||||
sa.Boolean(),
|
||||
nullable=True,
|
||||
server_default=sa.false(),
|
||||
)
|
||||
)
|
||||
batch_op.create_check_constraint("ck_conversations_kind", "kind IN (1, 2)")
|
||||
batch_op.create_check_constraint(
|
||||
"ck_conversations_workspace_required_for_host",
|
||||
"host_id IS NULL OR workspace IS NOT NULL",
|
||||
)
|
||||
|
||||
# 2. Restore data from metadata table back into conversations.
|
||||
# Use correlated subqueries to stay compatible with SQLite, MySQL,
|
||||
# and PostgreSQL (the UPDATE … FROM form is PostgreSQL-only).
|
||||
# ``kind`` is NOT NULL in the pre-split schema; default to 1
|
||||
# ("default") for any conversation without a matching metadata row.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE conversations
|
||||
SET kind = COALESCE(
|
||||
(SELECT m.kind
|
||||
FROM omnigent_conversation_metadata m
|
||||
WHERE m.workspace_id = conversations.workspace_id
|
||||
AND m.id = conversations.id),
|
||||
1
|
||||
)
|
||||
"""
|
||||
)
|
||||
# ``workspace`` must be restored BEFORE ``host_id``: the check constraint
|
||||
# re-created above (host_id IS NULL OR workspace IS NOT NULL) is checked
|
||||
# per statement, so restoring host_id first would fire it on every
|
||||
# host-bound row while its workspace is still NULL.
|
||||
for col in (
|
||||
"runner_id",
|
||||
"workspace",
|
||||
"host_id",
|
||||
"sub_agent_name",
|
||||
"external_session_id",
|
||||
"session_state",
|
||||
"session_usage",
|
||||
"terminal_launch_args",
|
||||
"git_branch",
|
||||
"archived",
|
||||
):
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE conversations
|
||||
SET {col} = (
|
||||
SELECT m.{col}
|
||||
FROM omnigent_conversation_metadata m
|
||||
WHERE m.workspace_id = conversations.workspace_id
|
||||
AND m.id = conversations.id
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 3. Re-create indexes that were dropped.
|
||||
op.create_index(
|
||||
"ix_conversations_kind",
|
||||
"conversations",
|
||||
["workspace_id", "kind", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_conversations_runner_id",
|
||||
"conversations",
|
||||
["workspace_id", "runner_id", "id"],
|
||||
)
|
||||
|
||||
# 4. Drop the metadata table.
|
||||
op.drop_index(
|
||||
"ix_conversation_metadata_runner_id", table_name="omnigent_conversation_metadata"
|
||||
)
|
||||
op.drop_index("ix_conversation_metadata_kind", table_name="omnigent_conversation_metadata")
|
||||
op.drop_table("omnigent_conversation_metadata")
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
"""Split agent binding and per-session overrides into agent_configuration.
|
||||
|
||||
Revision ID: bb2c3d4e5f6a
|
||||
Revises: aa1b2c3d4e5f
|
||||
Create Date: 2026-07-12 00:00:00.000000
|
||||
|
||||
Moves the agent binding and per-session config overrides out of the
|
||||
``conversations`` table into a new ``agent_configuration`` table (1-to-1
|
||||
paired by ``(workspace_id, conversation_id)``, same database). The
|
||||
columns moved are: ``agent_id``, ``reasoning_effort``,
|
||||
``model_override``, ``cost_control_mode_override``,
|
||||
``harness_override``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "bb2c3d4e5f6a"
|
||||
down_revision: str | None = "aa1b2c3d4e5f"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_MOVED_COLUMNS = (
|
||||
"agent_id",
|
||||
"reasoning_effort",
|
||||
"model_override",
|
||||
"cost_control_mode_override",
|
||||
"harness_override",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Create the new agent_configuration table.
|
||||
op.create_table(
|
||||
"agent_configuration",
|
||||
sa.Column(
|
||||
"workspace_id",
|
||||
sa.BigInteger(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.Column("conversation_id", sa.String(64), nullable=False),
|
||||
sa.Column("agent_id", sa.String(64), nullable=True),
|
||||
sa.Column("reasoning_effort", sa.String(32), nullable=True),
|
||||
sa.Column("model_override", sa.String(128), nullable=True),
|
||||
sa.Column("cost_control_mode_override", sa.String(8), nullable=True),
|
||||
sa.Column("harness_override", sa.String(64), nullable=True),
|
||||
sa.PrimaryKeyConstraint("workspace_id", "conversation_id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_agent_configuration_agent_id",
|
||||
"agent_configuration",
|
||||
["workspace_id", "agent_id", "conversation_id"],
|
||||
)
|
||||
|
||||
# 2. Copy data: one agent_configuration row per conversation.
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO agent_configuration
|
||||
(workspace_id, conversation_id, agent_id, reasoning_effort,
|
||||
model_override, cost_control_mode_override, harness_override)
|
||||
SELECT workspace_id, id, agent_id, reasoning_effort, model_override,
|
||||
cost_control_mode_override, harness_override
|
||||
FROM conversations
|
||||
"""
|
||||
)
|
||||
|
||||
# 3. Drop the moved index and columns from conversations.
|
||||
# Use batch_alter_table for SQLite compatibility.
|
||||
op.drop_index("ix_conversations_agent_id", table_name="conversations")
|
||||
with op.batch_alter_table("conversations") as batch_op:
|
||||
for col in _MOVED_COLUMNS:
|
||||
batch_op.drop_column(col)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 1. Re-add the moved columns to conversations.
|
||||
with op.batch_alter_table("conversations") as batch_op:
|
||||
batch_op.add_column(sa.Column("agent_id", sa.String(64), nullable=True))
|
||||
batch_op.add_column(sa.Column("reasoning_effort", sa.String(32), nullable=True))
|
||||
batch_op.add_column(sa.Column("model_override", sa.String(128), nullable=True))
|
||||
batch_op.add_column(sa.Column("cost_control_mode_override", sa.String(8), nullable=True))
|
||||
batch_op.add_column(sa.Column("harness_override", sa.String(64), nullable=True))
|
||||
|
||||
# 2. Restore data via correlated subqueries (portable across SQLite,
|
||||
# MySQL, and PostgreSQL; UPDATE ... FROM is PostgreSQL-only).
|
||||
for col in _MOVED_COLUMNS:
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE conversations
|
||||
SET {col} = (
|
||||
SELECT ac.{col}
|
||||
FROM agent_configuration ac
|
||||
WHERE ac.workspace_id = conversations.workspace_id
|
||||
AND ac.conversation_id = conversations.id
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 3. Re-create the dropped index, then drop the new table.
|
||||
op.create_index(
|
||||
"ix_conversations_agent_id",
|
||||
"conversations",
|
||||
["workspace_id", "agent_id", "id"],
|
||||
)
|
||||
op.drop_index("ix_agent_configuration_agent_id", table_name="agent_configuration")
|
||||
op.drop_table("agent_configuration")
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
"""Add (workspace_id, conversation_id, type, position DESC) index on conversation_items.
|
||||
|
||||
Revision ID: cc3d4e5f6a7b
|
||||
Revises: bb2c3d4e5f6a
|
||||
Create Date: 2026-07-14 00:00:00.000000
|
||||
|
||||
Adds a composite index that backs the latest-message-preview query
|
||||
(``list_latest_message_items_for_conversations`` /
|
||||
``_ranked_latest_message_items``) powering the child-session sidebar:
|
||||
|
||||
SELECT ... FROM conversation_items
|
||||
WHERE workspace_id = ? AND conversation_id IN (...) AND type = 'message'
|
||||
-- ranked per conversation by position DESC, top-N kept
|
||||
|
||||
The existing unique index ``(workspace_id, conversation_id, position)`` covers
|
||||
the partition + order but not the ``type`` filter, so Postgres reads every
|
||||
item in the matched conversations and rechecks ``type`` on the heap —
|
||||
discarding the majority (messages are a minority of items in agent
|
||||
transcripts). Ordering ``type`` before ``position`` lets the scan seek to
|
||||
``(workspace_id, conversation_id, type)`` and walk ``position DESC`` directly.
|
||||
|
||||
Plain (non-partial) index so it builds identically on SQLite, PostgreSQL, and
|
||||
MySQL — the codebase dropped partial indexes for MySQL compatibility in
|
||||
``z5a2b3c4d5e6``. DESC ordering is expressed via ``sa.text`` because Alembic's
|
||||
column list takes no per-column sort direction; all three dialects honor DESC
|
||||
in a ``CREATE INDEX`` column list.
|
||||
|
||||
Index-only: ``CREATE INDEX`` / ``DROP INDEX`` are native on every dialect, so
|
||||
no batch table-rebuild (and no SQLite ``foreign_keys`` guard) is needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "cc3d4e5f6a7b"
|
||||
down_revision: str | None = "bb2c3d4e5f6a"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_index(
|
||||
"ix_conversation_items_conv_type_position",
|
||||
"conversation_items",
|
||||
["workspace_id", "conversation_id", "type", sa.text("position DESC")],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_conversation_items_conv_type_position",
|
||||
table_name="conversation_items",
|
||||
)
|
||||
@@ -1,137 +0,0 @@
|
||||
"""add scheduled_tasks and scheduled_task_runs tables
|
||||
|
||||
Revision ID: z6a2b3c4d5e6
|
||||
Revises: 9d820f91deef
|
||||
Create Date: 2026-07-09 00:00:00.000000
|
||||
|
||||
Adds the ``scheduled_tasks`` table (saved, scheduled agent instructions) and its
|
||||
``scheduled_task_runs`` history table (one row per firing).
|
||||
|
||||
The task trigger is a required recurring ``cron_expression``: every task fires
|
||||
on a cron schedule, so ``cron_expression`` is NOT NULL.
|
||||
|
||||
Both tables are brand-new and are created at the current schema state, so each
|
||||
carries the tenant-partition ``workspace_id`` column as the leading primary-key
|
||||
member (matching every other table after ``r1a2b3c4d5e6``). There are no
|
||||
foreign-key constraints (schema Rule R032 — see ``p1a2b3c4d5e6``): the
|
||||
``agent_id`` / ``conversation_id`` / ``scheduled_task_id`` relationships are
|
||||
enforced by the application, not the database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from omnigent.db.db_models import Uuid16
|
||||
|
||||
revision: str = "z6a2b3c4d5e6"
|
||||
down_revision: str | None = "9d820f91deef"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create the ``scheduled_tasks`` and ``scheduled_task_runs`` tables."""
|
||||
op.create_table(
|
||||
"scheduled_tasks",
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
# UUID PK stored as 16 raw bytes (Uuid16 → BINARY(16) on MySQL, BLOB/BYTEA
|
||||
# elsewhere).
|
||||
sa.Column("id", Uuid16(), nullable=False),
|
||||
sa.Column("name", sa.String(256), nullable=False),
|
||||
# Opaque free text stored compressed (CompressedText → LargeBinary).
|
||||
sa.Column("prompt", sa.LargeBinary(), nullable=False),
|
||||
# Recurring trigger: a required cron string (e.g. "0 9 * * *").
|
||||
sa.Column("cron_expression", sa.String(255), nullable=False),
|
||||
sa.Column("owner_user_id", sa.String(128), nullable=True),
|
||||
sa.Column("agent_id", sa.String(64), nullable=False),
|
||||
sa.Column("model_override", sa.String(128), nullable=True),
|
||||
sa.Column("reasoning_effort", sa.String(32), nullable=True),
|
||||
sa.Column("workspace", sa.String(2048), nullable=True),
|
||||
# Git base ref a firing branches from when it creates a worktree.
|
||||
sa.Column("base_branch", sa.String(255), nullable=True),
|
||||
# Where a firing runs, as a stable int code (see omnigent.db.enum_codecs
|
||||
# SCHEDULED_TASK_EXECUTION_TARGET: connected_host=1, managed_sandbox=2).
|
||||
# Defaults to connected_host so existing rows keep the V1 behavior.
|
||||
sa.Column("execution_target", sa.SmallInteger(), nullable=False, server_default="1"),
|
||||
# For execution_target=connected_host: the specific host to run on
|
||||
# (relates to hosts.host_id; no DB FK, Rule R032). NULL = owner's
|
||||
# freshest online host. Always NULL for managed_sandbox.
|
||||
sa.Column("host_id", sa.String(64), nullable=True),
|
||||
sa.Column("timezone", sa.String(64), nullable=False, server_default="UTC"),
|
||||
# Enum stored as a stable int code (see omnigent.db.enum_codecs
|
||||
# SCHEDULED_TASK_STATE: active=1, paused=2, deleted=3).
|
||||
sa.Column("state", sa.SmallInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("last_run_at", sa.Integer(), nullable=True),
|
||||
sa.Column("last_run_conversation_id", sa.String(64), nullable=True),
|
||||
sa.Column("created_at", sa.Integer(), nullable=False),
|
||||
sa.Column("updated_at", sa.Integer(), nullable=True),
|
||||
sa.CheckConstraint("state IN (1, 2, 3)", name="ck_scheduled_tasks_state"),
|
||||
sa.CheckConstraint(
|
||||
"execution_target IN (1, 2)", name="ck_scheduled_tasks_execution_target"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("workspace_id", "id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scheduled_tasks_created_at",
|
||||
"scheduled_tasks",
|
||||
["workspace_id", "created_at", "id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scheduled_tasks_owner_user_id",
|
||||
"scheduled_tasks",
|
||||
["workspace_id", "owner_user_id", "id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scheduled_tasks_state",
|
||||
"scheduled_tasks",
|
||||
["workspace_id", "state", "created_at", "id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"scheduled_task_runs",
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
# UUID PK + self-ref stored as 16 raw bytes (Uuid16). conversation_id
|
||||
# relates to conversations.id (String) and stays a String column.
|
||||
sa.Column("id", Uuid16(), nullable=False),
|
||||
sa.Column("scheduled_task_id", Uuid16(), nullable=False),
|
||||
sa.Column("conversation_id", sa.String(64), nullable=True),
|
||||
# Enum stored as a stable int code (see omnigent.db.enum_codecs
|
||||
# SCHEDULED_TASK_RUN_STATUS: scheduled=1, running=2, succeeded=3,
|
||||
# failed=4, skipped=5).
|
||||
sa.Column("status", sa.SmallInteger(), nullable=False),
|
||||
sa.Column("scheduled_at", sa.Integer(), nullable=False),
|
||||
sa.Column("fired_at", sa.Integer(), nullable=True),
|
||||
sa.Column("finished_at", sa.Integer(), nullable=True),
|
||||
# Opaque free-text error blob stored compressed (CompressedText → LargeBinary).
|
||||
sa.Column("error", sa.LargeBinary(), nullable=True),
|
||||
# Short, queryable failure classification token for future retry logic.
|
||||
sa.Column("error_code", sa.String(64), nullable=True),
|
||||
sa.CheckConstraint(
|
||||
"status IN (1, 2, 3, 4, 5)",
|
||||
name="ck_scheduled_task_runs_status",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("workspace_id", "id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scheduled_task_runs_scheduled_task_id",
|
||||
"scheduled_task_runs",
|
||||
["workspace_id", "scheduled_task_id", "scheduled_at", "id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop the ``scheduled_task_runs`` and ``scheduled_tasks`` tables."""
|
||||
op.drop_index("ix_scheduled_task_runs_scheduled_task_id", table_name="scheduled_task_runs")
|
||||
op.drop_table("scheduled_task_runs")
|
||||
op.drop_index("ix_scheduled_tasks_state", table_name="scheduled_tasks")
|
||||
op.drop_index("ix_scheduled_tasks_owner_user_id", table_name="scheduled_tasks")
|
||||
op.drop_index("ix_scheduled_tasks_created_at", table_name="scheduled_tasks")
|
||||
op.drop_table("scheduled_tasks")
|
||||
@@ -1,362 +0,0 @@
|
||||
"""Convert opaque uuid id columns from prefixed strings to 16-byte binary.
|
||||
|
||||
Revision ID: z7a2b3c4d5e6
|
||||
Revises: z6a2b3c4d5e6
|
||||
Create Date: 2026-07-09 00:00:00.000000
|
||||
|
||||
Our ids were opaque prefixed strings — ``ag_<hex>``, ``conv_<hex>``,
|
||||
``host_<hex>``, per-type conversation-item prefixes (``msg_``/``fc_``/…),
|
||||
``pol_<hex>``, and the dashed canonical uuid for comments. This migration drops
|
||||
the prefixes and stores each id as the 16 raw bytes of its uuid: ``BYTEA``
|
||||
(PostgreSQL), ``BLOB`` (SQLite / Cloudflare D1), ``BINARY(16)`` (MySQL) — the
|
||||
``Uuid16`` column type. The rest of the system keeps the readable bare 32-char
|
||||
hex form (entities, JSON blobs, URLs, the FTS mirror), so only the physical
|
||||
column changes.
|
||||
|
||||
Columns deliberately NOT converted (kept as strings):
|
||||
``omnigent_conversation_metadata.runner_id`` and
|
||||
``conversation_items.response_id`` (polymorphic harness task tokens, not our
|
||||
uuids), ``omnigent_conversation_metadata.external_session_id`` (harness-native),
|
||||
``agents.bundle_location`` (a physical artifact-store key ``<agent_id>/<sha>``),
|
||||
``account_tokens.id`` (a secret token), ``hosts.token_hash`` (a sha256), and the
|
||||
email / username identity columns.
|
||||
|
||||
Strip rule (uniform): drop any dashes, take the trailing 32 hex chars, decode.
|
||||
This reduces the ``conv_``/``ag_``/item-prefixed / dashed / already-bare forms
|
||||
all to the same 16 bytes, and is idempotent on a bare id.
|
||||
|
||||
Total-transform fallback: a value whose trailing 32 chars are not valid hex
|
||||
(hand-crafted junk such as an external monitor's ``host_fix_<epoch>`` /
|
||||
``host_probe_<epoch>`` host id) would make ``decode``/``UNHEX``/``bytes.fromhex``
|
||||
raise and abort the whole migration. Rather than block the deploy or drop the
|
||||
row, such a value maps to ``md5(value)`` (16 bytes). Because ``md5`` is a pure
|
||||
function of the string and identical across PostgreSQL, MySQL, and Python, a
|
||||
junk value and every column that references it (e.g. ``hosts.host_id`` and the
|
||||
``omnigent_conversation_metadata.host_id`` copies) map to the SAME bytes, so
|
||||
cross-references still resolve. A well-formed id always takes the hex-decode
|
||||
branch, so this changes nothing for normal data.
|
||||
|
||||
Also rewrites the embedded ``"session_id": "conv_<hex>"`` copy inside
|
||||
``conversation_items.data`` (a plain ``Text`` column) and strips the mirrored
|
||||
prefixes from the SQLite FTS shadow table, so those cross-references keep
|
||||
resolving against the now-bare ids.
|
||||
|
||||
Downgrade restores string columns holding the bare 32-char hex form. It cannot
|
||||
reintroduce the dropped prefixes — they carried no information (the item type
|
||||
lives in ``conversation_items.type`` and ids are opaque) — so downgrade is
|
||||
one-way on the prefix.
|
||||
|
||||
The MySQL path is modelled on standard MySQL semantics but is not exercised by
|
||||
the local (SQLite) or CI test paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# A bare 32-char lowercase-hex uuid — the form every id reduces to after
|
||||
# stripping dashes and any prefix. A value matching this is decoded directly;
|
||||
# anything else is a non-uuid and falls back to md5 (see the module docstring).
|
||||
_BARE_HEX_RE = re.compile(r"^[0-9a-f]{32}$")
|
||||
|
||||
revision: str = "z7a2b3c4d5e6"
|
||||
down_revision: str | None = "z6a2b3c4d5e6"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
# Table -> id columns holding one of our opaque uuids. Every column here is
|
||||
# ``String(64)`` before this migration and ``Uuid16`` (16 raw bytes) after.
|
||||
_BINARY_ID_COLUMNS: dict[str, list[str]] = {
|
||||
"agents": ["id"],
|
||||
"files": ["id", "session_id"],
|
||||
"session_permissions": ["conversation_id"],
|
||||
"conversations": [
|
||||
"id",
|
||||
"parent_conversation_id",
|
||||
"root_conversation_id",
|
||||
],
|
||||
# The conversations split (aa1b/bb2c) copied prefixed ids into these two
|
||||
# tables before this migration runs, so their copies are converted too.
|
||||
"omnigent_conversation_metadata": ["id", "host_id"],
|
||||
"agent_configuration": ["conversation_id", "agent_id"],
|
||||
"conversation_items": ["id", "conversation_id"],
|
||||
"conversation_labels": ["conversation_id"],
|
||||
"comments": ["id", "conversation_id"],
|
||||
"policies": ["id", "session_id"],
|
||||
"hosts": ["host_id"],
|
||||
# scheduled-task tables (added on main just before this migration). Their
|
||||
# own PKs (id, scheduled_task_id) are already created as Uuid16/binary by
|
||||
# that migration; only the string reference columns pointing at converted
|
||||
# tables need converting here.
|
||||
"scheduled_tasks": ["agent_id", "host_id", "last_run_conversation_id"],
|
||||
"scheduled_task_runs": ["conversation_id"],
|
||||
}
|
||||
|
||||
_FTS_TABLE = "conversation_items_fts"
|
||||
_FTS_DIALECTS = frozenset({"sqlite", "cloudflare_d1"})
|
||||
|
||||
|
||||
def _id_to_bytes(value: object) -> bytes:
|
||||
"""Strip prefix/dashes from an id string and return its 16 raw bytes.
|
||||
|
||||
A value whose trailing 32 chars are valid hex is decoded; any other value
|
||||
(non-uuid junk) falls back to ``md5(value)`` so the conversion never fails.
|
||||
See the module docstring for why this preserves cross-references.
|
||||
"""
|
||||
if isinstance(value, (bytes, bytearray)): # already converted (idempotent)
|
||||
return bytes(value)
|
||||
text_value = str(value)
|
||||
bare = text_value.replace("-", "")[-32:]
|
||||
if _BARE_HEX_RE.match(bare):
|
||||
return bytes.fromhex(bare)
|
||||
# md5 is a stable remap for non-uuid junk, not a security primitive.
|
||||
return hashlib.md5(text_value.encode()).digest()
|
||||
|
||||
|
||||
def _bytes_to_id(value: object) -> str:
|
||||
"""Return the bare 32-char hex form of a stored 16-byte id (downgrade)."""
|
||||
if isinstance(value, str): # already hex (idempotent)
|
||||
return value.replace("-", "")[-32:]
|
||||
return bytes(value).hex()
|
||||
|
||||
|
||||
def _nullability(bind: sa.Connection) -> dict[tuple[str, str], bool]:
|
||||
"""Reflect current NULL-ability for every converted column."""
|
||||
insp = sa.inspect(bind)
|
||||
result: dict[tuple[str, str], bool] = {}
|
||||
for table, cols in _BINARY_ID_COLUMNS.items():
|
||||
by_name = {c["name"]: bool(c["nullable"]) for c in insp.get_columns(table)}
|
||||
for col in cols:
|
||||
result[(table, col)] = by_name[col]
|
||||
return result
|
||||
|
||||
|
||||
def _fts_present(bind: sa.Connection) -> bool:
|
||||
row = bind.execute(
|
||||
sa.text("SELECT 1 FROM sqlite_master WHERE type='table' AND name=:n").bindparams(
|
||||
n=_FTS_TABLE
|
||||
)
|
||||
).first()
|
||||
return row is not None
|
||||
|
||||
|
||||
# ── upgrade ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Convert the id columns to 16-byte binary and fix the embedded copies."""
|
||||
bind = op.get_bind()
|
||||
dialect = bind.dialect.name
|
||||
nullable = _nullability(bind)
|
||||
|
||||
if dialect == "postgresql":
|
||||
_upgrade_postgresql()
|
||||
elif dialect == "mysql":
|
||||
_upgrade_mysql(nullable)
|
||||
else: # sqlite / cloudflare_d1
|
||||
_upgrade_sqlite(bind, nullable)
|
||||
|
||||
_rewrite_embedded_session_id()
|
||||
|
||||
if dialect in _FTS_DIALECTS and _fts_present(bind):
|
||||
op.execute(
|
||||
sa.text(
|
||||
f"UPDATE {_FTS_TABLE} SET "
|
||||
"item_id = substr(item_id, -32), "
|
||||
"conversation_id = substr(conversation_id, -32)"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _upgrade_postgresql() -> None:
|
||||
"""One atomic ALTER per column: strip prefix/dashes and decode hex -> bytea.
|
||||
|
||||
A value whose trailing 32 chars are valid hex is decoded; any other value
|
||||
falls back to ``decode(md5(col), 'hex')`` — the same 16 bytes Python's
|
||||
``_id_to_bytes`` and MySQL's ``UNHEX(MD5(col))`` produce — so the ALTER
|
||||
never raises on junk and referencing columns stay consistent.
|
||||
"""
|
||||
for table, cols in _BINARY_ID_COLUMNS.items():
|
||||
for col in cols:
|
||||
stripped = f"right(replace(\"{col}\", '-', ''), 32)"
|
||||
op.execute(
|
||||
sa.text(
|
||||
f'ALTER TABLE "{table}" ALTER COLUMN "{col}" TYPE bytea USING '
|
||||
f"CASE WHEN {stripped} ~ '^[0-9a-f]{{32}}$' "
|
||||
f"THEN decode({stripped}, 'hex') "
|
||||
f"ELSE decode(md5(\"{col}\"), 'hex') END"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _upgrade_mysql(nullable: dict[tuple[str, str], bool]) -> None:
|
||||
"""Reinterpret as binary, decode the trailing 32 chars, then fix to BINARY(16).
|
||||
|
||||
A value whose trailing 32 chars are valid hex is decoded via ``UNHEX``; any
|
||||
other value falls back to ``UNHEX(MD5(col))`` — the same 16 bytes the SQLite
|
||||
(``_id_to_bytes``) and PostgreSQL (``decode(md5(col),'hex')``) paths produce
|
||||
— so ``UNHEX`` never returns NULL on junk and referencing columns stay
|
||||
consistent. A post-UPDATE NULL guard remains as a belt-and-braces check that
|
||||
no non-NULL value slipped through to NULL before the NOT NULL type change.
|
||||
|
||||
The interim ``VARBINARY(64)`` reinterpret keeps the column's real
|
||||
nullability (``null_sql``): MySQL rejects making a PRIMARY KEY column NULL
|
||||
even transiently (error 1171), and the CASE/UNHEX always yields 16 bytes,
|
||||
so no NOT NULL column ever needs to hold NULL mid-conversion.
|
||||
|
||||
The value expression reads the column through ``CONVERT(... USING utf8mb4)``:
|
||||
after the interim reinterpret the column is binary, and MySQL refuses
|
||||
``REGEXP`` on a binary string against a utf8mb4 pattern (error 3995), so the
|
||||
original ASCII hex is recovered as text before the regex/UNHEX/MD5 run.
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
for table, cols in _BINARY_ID_COLUMNS.items():
|
||||
for col in cols:
|
||||
null_sql = "NULL" if nullable[(table, col)] else "NOT NULL"
|
||||
count_nulls = sa.text(f"SELECT COUNT(*) FROM `{table}` WHERE `{col}` IS NULL")
|
||||
nulls_before = bind.execute(count_nulls).scalar_one()
|
||||
op.execute(sa.text(f"ALTER TABLE `{table}` MODIFY `{col}` VARBINARY(64) {null_sql}"))
|
||||
col_text = f"CONVERT(`{col}` USING utf8mb4)" # binary -> text for regex/UNHEX/MD5
|
||||
stripped = f"RIGHT(REPLACE({col_text}, '-', ''), 32)"
|
||||
op.execute(
|
||||
sa.text(
|
||||
f"UPDATE `{table}` SET `{col}` = "
|
||||
f"CASE WHEN {stripped} REGEXP '^[0-9a-f]{{32}}$' "
|
||||
f"THEN UNHEX({stripped}) "
|
||||
f"ELSE UNHEX(MD5({col_text})) END "
|
||||
f"WHERE `{col}` IS NOT NULL"
|
||||
)
|
||||
)
|
||||
nulls_after = bind.execute(count_nulls).scalar_one()
|
||||
if nulls_after != nulls_before:
|
||||
raise RuntimeError(
|
||||
f"id conversion would lose data: {nulls_after - nulls_before} "
|
||||
f"value(s) in `{table}`.`{col}` unexpectedly became NULL; "
|
||||
f"aborting before the type change"
|
||||
)
|
||||
op.execute(sa.text(f"ALTER TABLE `{table}` MODIFY `{col}` BINARY(16) {null_sql}"))
|
||||
|
||||
|
||||
def _upgrade_sqlite(bind: sa.Connection, nullable: dict[tuple[str, str], bool]) -> None:
|
||||
"""Convert values to raw bytes in place, then change the declared type to BLOB.
|
||||
|
||||
A bound ``bytes`` value is stored verbatim as a BLOB even while the column is
|
||||
still declared ``TEXT`` — SQLite's TEXT affinity does not coerce a BLOB — so
|
||||
the subsequent batch type change copies real 16-byte values, not hex text.
|
||||
"""
|
||||
op.execute(sa.text("PRAGMA foreign_keys = OFF"))
|
||||
|
||||
for table, cols in _BINARY_ID_COLUMNS.items():
|
||||
select_cols = ", ".join(f'"{c}"' for c in cols)
|
||||
rows = bind.execute(sa.text(f'SELECT rowid, {select_cols} FROM "{table}"')).fetchall()
|
||||
for row in rows:
|
||||
assignments = {
|
||||
col: _id_to_bytes(row[idx])
|
||||
for idx, col in enumerate(cols, start=1)
|
||||
if row[idx] is not None
|
||||
}
|
||||
if assignments:
|
||||
set_clause = ", ".join(f'"{c}" = :{c}' for c in assignments)
|
||||
bind.execute(
|
||||
sa.text(f'UPDATE "{table}" SET {set_clause} WHERE rowid = :__rowid'),
|
||||
{**assignments, "__rowid": row[0]},
|
||||
)
|
||||
|
||||
for table, cols in _BINARY_ID_COLUMNS.items():
|
||||
with op.batch_alter_table(table) as batch:
|
||||
for col in cols:
|
||||
batch.alter_column(
|
||||
col,
|
||||
type_=sa.LargeBinary(16),
|
||||
existing_type=sa.String(64),
|
||||
existing_nullable=nullable[(table, col)],
|
||||
)
|
||||
|
||||
op.execute(sa.text("PRAGMA foreign_keys = ON"))
|
||||
|
||||
|
||||
def _rewrite_embedded_session_id() -> None:
|
||||
"""Strip the ``conv_`` prefix from the ``session_id`` echoed inside
|
||||
``conversation_items.data`` (plain Text; both JSON spacings handled).
|
||||
|
||||
Scoped to ``type = 8`` (the ``resource_event`` enum code): only that item
|
||||
type carries a structural ``session_id`` field. Message items may contain
|
||||
the same byte sequence inside user/assistant prose (pasted JSON, debug
|
||||
transcripts) — rewriting those would silently corrupt chat history.
|
||||
"""
|
||||
for old in ('"session_id": "conv_', '"session_id":"conv_'):
|
||||
new = old.replace("conv_", "")
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE conversation_items SET data = REPLACE(data, :old, :new) "
|
||||
"WHERE type = 8 AND data LIKE :like"
|
||||
).bindparams(old=old, new=new, like=f"%{old}%")
|
||||
)
|
||||
|
||||
|
||||
# ── downgrade ───────────────────────────────────────────
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Restore String(64) columns holding the bare 32-char hex form (no prefix)."""
|
||||
bind = op.get_bind()
|
||||
dialect = bind.dialect.name
|
||||
nullable = _nullability(bind)
|
||||
|
||||
if dialect == "postgresql":
|
||||
for table, cols in _BINARY_ID_COLUMNS.items():
|
||||
for col in cols:
|
||||
op.execute(
|
||||
sa.text(
|
||||
f'ALTER TABLE "{table}" ALTER COLUMN "{col}" TYPE varchar(64) '
|
||||
f"USING encode(\"{col}\", 'hex')"
|
||||
)
|
||||
)
|
||||
elif dialect == "mysql":
|
||||
for table, cols in _BINARY_ID_COLUMNS.items():
|
||||
for col in cols:
|
||||
null_sql = "NULL" if nullable[(table, col)] else "NOT NULL"
|
||||
# Interim reinterpret keeps real nullability — MySQL rejects a
|
||||
# transiently-NULL PK column (error 1171).
|
||||
op.execute(
|
||||
sa.text(f"ALTER TABLE `{table}` MODIFY `{col}` VARBINARY(64) {null_sql}")
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
f"UPDATE `{table}` SET `{col}` = LOWER(HEX(`{col}`)) "
|
||||
f"WHERE `{col}` IS NOT NULL"
|
||||
)
|
||||
)
|
||||
op.execute(sa.text(f"ALTER TABLE `{table}` MODIFY `{col}` VARCHAR(64) {null_sql}"))
|
||||
else: # sqlite / cloudflare_d1
|
||||
op.execute(sa.text("PRAGMA foreign_keys = OFF"))
|
||||
for table, cols in _BINARY_ID_COLUMNS.items():
|
||||
select_cols = ", ".join(f'"{c}"' for c in cols)
|
||||
rows = bind.execute(sa.text(f'SELECT rowid, {select_cols} FROM "{table}"')).fetchall()
|
||||
for row in rows:
|
||||
assignments = {
|
||||
col: _bytes_to_id(row[idx])
|
||||
for idx, col in enumerate(cols, start=1)
|
||||
if row[idx] is not None
|
||||
}
|
||||
if assignments:
|
||||
set_clause = ", ".join(f'"{c}" = :{c}' for c in assignments)
|
||||
bind.execute(
|
||||
sa.text(f'UPDATE "{table}" SET {set_clause} WHERE rowid = :__rowid'),
|
||||
{**assignments, "__rowid": row[0]},
|
||||
)
|
||||
for table, cols in _BINARY_ID_COLUMNS.items():
|
||||
with op.batch_alter_table(table) as batch:
|
||||
for col in cols:
|
||||
batch.alter_column(
|
||||
col,
|
||||
type_=sa.String(64),
|
||||
existing_type=sa.LargeBinary(16),
|
||||
existing_nullable=nullable[(table, col)],
|
||||
)
|
||||
op.execute(sa.text("PRAGMA foreign_keys = ON"))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user