Compare commits
191 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08285468e0 | |||
| 86088d6a8a | |||
| 1d1b3a8605 | |||
| 3d91adf903 | |||
| 3cc8bb205f | |||
| 9567ce3c74 | |||
| 5defa1955f | |||
| 25b8e200f3 | |||
| 713573cceb | |||
| 3526e2b64f | |||
| 60b5f9ac9f | |||
| 7aace8eb7f | |||
| 0540942062 | |||
| d74984330c | |||
| 10532c9d6f | |||
| 4ab0216bb0 | |||
| 531931f95c | |||
| 7b2871da8c | |||
| 766fd26226 | |||
| 60e775a267 | |||
| adf04793cf | |||
| 1141dc3973 | |||
| 164a46eee9 | |||
| 476beffd3c | |||
| d677bd98f1 | |||
| 60b9f40991 | |||
| 4ba52571ae | |||
| fbf2f655f0 | |||
| bc140bc5c0 | |||
| 76bb9002d9 | |||
| a75b64a4b3 | |||
| 46e3cd9754 | |||
| 55764b6da4 | |||
| 5b04596a08 | |||
| e89d6a0c8e | |||
| ac9e49cc31 | |||
| 5b91f425eb | |||
| 7fb779fdef | |||
| 86e6abdbbe | |||
| 7afc6433b2 | |||
| eed3845851 | |||
| 6d55390440 | |||
| 335cab5475 | |||
| 65264eedf3 | |||
| 2eba6bc3b8 | |||
| c49cd59692 | |||
| 5ad635a47d | |||
| e3b2548b80 | |||
| 45da783590 | |||
| 936d65c141 | |||
| 255a5f8f10 | |||
| a89fa733e2 | |||
| ea243f5f45 | |||
| 777f75781c | |||
| 91a1897dc1 | |||
| 5b41677443 | |||
| 1d410b8583 | |||
| 0f8d2288e9 | |||
| 14ffae4672 | |||
| dfa856f6dd | |||
| e69af6b358 | |||
| 85f29f539a | |||
| ea5a2ce441 | |||
| 7044f0c091 | |||
| 91d6746b44 | |||
| 0d49253e78 | |||
| de1a268ff5 | |||
| 49a649f19a | |||
| eae151dff7 | |||
| 4dbe259d3e | |||
| 86ad734963 | |||
| 06ba29f83f | |||
| ac19316854 | |||
| f2c1594a4a | |||
| 760333275c | |||
| 3c7a558ce5 | |||
| 2bb916b058 | |||
| 64762f2979 | |||
| 904aba1870 | |||
| bd0ebcf18d | |||
| 238c7660be | |||
| 9fcf2c4f9d | |||
| 040bfd7fed | |||
| b7db521f2a | |||
| 4da25975cf | |||
| 5b40494c92 | |||
| c2822b389a | |||
| fbe38632a1 | |||
| f1226aaa51 | |||
| 18a2f025a0 | |||
| 37913e67a2 | |||
| 49eb088544 | |||
| 8ab45a8256 | |||
| 20ccef117f | |||
| aa53f689df | |||
| 0122b7f292 | |||
| c910c47a46 | |||
| ab7002cb9c | |||
| 7572d965a0 | |||
| e52e938e4c | |||
| 78048a3ab2 | |||
| e35593ffa9 | |||
| 8810963c90 | |||
| 1aca7bc9e5 | |||
| d63ca5dfda | |||
| 5196f8cfb5 | |||
| 235a4eafb3 | |||
| 20bb6c7469 | |||
| d1c418bd40 | |||
| 8fffc13560 | |||
| 2f59c89271 | |||
| 127331884e | |||
| e689084e8e | |||
| c3af15235b | |||
| 5c580d3fae | |||
| 0f1114d1bd | |||
| 23ffb4b563 | |||
| c52dc80ca6 | |||
| c22b17581f | |||
| d447addcbc | |||
| 91714a2219 | |||
| 5b24dda378 | |||
| 42177d0e39 | |||
| c766433fe4 | |||
| d356b82ac0 | |||
| 473fb8123f | |||
| d526b2a196 | |||
| e8642d3ee3 | |||
| 8140027a9b | |||
| 4947f871a1 | |||
| 4225464ebd | |||
| e7fac09d9a | |||
| 77b211cd72 | |||
| ae3bbebe69 | |||
| a1ddce3b03 | |||
| ac39c38a88 | |||
| 1d165d160b | |||
| c641d0deff | |||
| 90b0cbe72e | |||
| 7d9dd710d2 | |||
| 4845b82187 | |||
| fc0a4dae42 | |||
| d7e74a0d64 | |||
| 279b7e0c13 | |||
| 79aa9963a8 | |||
| 511932e83c | |||
| 012721e4d0 | |||
| 7a8fcf931b | |||
| 75a5ec58db | |||
| 62b4254aff | |||
| e6cbd35410 | |||
| 53883864af | |||
| 52ec40109d | |||
| e83b11ea1a | |||
| 8236c72890 | |||
| a0e6f511ec | |||
| e1ee55aa4d | |||
| 541b451338 | |||
| 5269f70ecf | |||
| a5818fc8b0 | |||
| 779aa99385 | |||
| 6a97848fc6 | |||
| 16a636366e | |||
| ecb7350cde | |||
| 7fc9cee923 | |||
| 3d230c50be | |||
| 25307a9be2 | |||
| 8a482c1cd7 | |||
| 156cb03190 | |||
| de651a9f83 | |||
| c2060cdf90 | |||
| 687db94b32 | |||
| 31db1dcafe | |||
| 8552d68c7e | |||
| 2d18ec2cd0 | |||
| 8452ce39d5 | |||
| a4d0f2789e | |||
| 47aedd525f | |||
| 61f6b725b5 | |||
| 50faf0200b | |||
| 6b48cb06fe | |||
| dfde90dc2f | |||
| 5315349c83 | |||
| b3e220ba97 | |||
| e8313ac5d0 | |||
| 5508060e99 | |||
| 2a1d793815 | |||
| e5bd7cc0f3 | |||
| 427c3b4441 | |||
| 6e8fc19663 | |||
| 9125532066 |
@@ -21,3 +21,4 @@ shivam5
|
||||
TomeHirata
|
||||
xq-yin
|
||||
hzub
|
||||
zhengwin
|
||||
|
||||
@@ -91,7 +91,9 @@ prompt: |
|
||||
Read `DIFF_FILE` (with `sys_os_read`) carefully — it is your source of truth.
|
||||
Pull exact facts (flags, defaults, harness ids, CLI names, config keys) from the
|
||||
diff itself. Never invent a fact; if the diff doesn't settle something a doc must
|
||||
state, flag it for manual review rather than guessing.
|
||||
state, flag it for manual review rather than guessing. Note whether the PR
|
||||
**adds**, **changes**, or **removes/deprecates** a user-facing feature — that
|
||||
decides whether you add, edit, or delete docs (Step 3).
|
||||
|
||||
## Step 2 — Inspect the live site and decide placement
|
||||
This is why you have the whole site checked out. Read
|
||||
@@ -116,7 +118,23 @@ prompt: |
|
||||
## Step 3 — Write the edit (scoped, grounded, in-style)
|
||||
Make the change. Editing an existing `page.mdx` in place is best when one fits;
|
||||
otherwise create the new page and wire it into the nav. Keep the change scoped
|
||||
to what this PR introduced. Be accurate and concise — no marketing fluff.
|
||||
to what this PR introduced, changed, or removed. Be accurate and concise — no
|
||||
marketing fluff.
|
||||
|
||||
When the PR **removes or deprecates** a user-facing feature, the docs must
|
||||
shrink to match — treat this as first-class as adding docs, never as a no-op:
|
||||
- **Feature removed**: delete the now-untrue content. If a whole page documented
|
||||
only that feature, delete the `page.mdx` (with `sys_os_shell` `git rm`) AND
|
||||
remove its entry from the `SECTIONS` array in
|
||||
`components/DocsSidebarFull.js`. If it was one section of a larger page, cut
|
||||
that section and any references, table rows, or links pointing at it. Leave
|
||||
no dangling nav entry or cross-link to a page you deleted.
|
||||
- **Feature deprecated (not yet gone)**: keep the page but mark it deprecated in
|
||||
the site's usual style and state the replacement/removal timeline if the diff
|
||||
gives one; don't delete prematurely.
|
||||
Ground the removal in the diff: only delete docs for what the PR actually
|
||||
removed. If you're unsure whether a doc references the removed feature elsewhere
|
||||
on the site, flag it under "Manual review needed" rather than guessing.
|
||||
|
||||
Match the site's conventions by mirroring a real file:
|
||||
- **Existing page**: preserve its `pageMeta(...)` frontmatter and JSX component
|
||||
@@ -141,10 +159,17 @@ prompt: |
|
||||
the affected `<img>` (MDX supports JSX comments; the build is unaffected).
|
||||
|
||||
## Output contract (your final assistant text)
|
||||
After a line containing exactly `<!-- DOC_DRAFT_SUMMARY -->`, emit:
|
||||
- `## Changes documented` — one bullet per file you created or edited (pages and
|
||||
`components/DocsSidebarFull.js`): `path — what changed`. If you made no edits,
|
||||
write `_No edits made._` and explain under the next section.
|
||||
On the line IMMEDIATELY BEFORE `<!-- DOC_DRAFT_SUMMARY -->`, emit a single
|
||||
`DOC_PR_TITLE:` line — a concise, imperative summary of what the docs now cover,
|
||||
grounded in the diff (e.g. `DOC_PR_TITLE: document SMALLINT enum-column storage`).
|
||||
Keep it under 60 characters, no trailing period, and do NOT prefix it with
|
||||
`docs:` (the workflow adds that). This becomes the docs PR title.
|
||||
|
||||
Then, after a line containing exactly `<!-- DOC_DRAFT_SUMMARY -->`, emit:
|
||||
- `## Changes documented` — one bullet per file you created, edited, or deleted
|
||||
(pages and `components/DocsSidebarFull.js`): `path — what changed` (say
|
||||
"deleted" / "removed section" for removals). If you made no edits, write
|
||||
`_No edits made._` and explain under the next section.
|
||||
- `## Manual review needed` — a checklist: `- [ ] <doc path or area> — <why>`.
|
||||
Use this for things you genuinely cannot do well: stale screenshots/GIFs (you
|
||||
can't regenerate binaries), or a placement decision you're truly unsure about.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
REQUIRED=(
|
||||
"Pre-commit checks"
|
||||
"Docker build"
|
||||
"Pytest (runtime-harnesses)"
|
||||
"Pytest (runtime-policies)"
|
||||
"Pytest (runtime-core)"
|
||||
@@ -20,6 +21,8 @@ REQUIRED=(
|
||||
"Pytest (server-responses)"
|
||||
"Pytest (server-rest)"
|
||||
"Pytest (spec-llms)"
|
||||
"Pytest (runner-app)"
|
||||
"Pytest (stores)"
|
||||
"Pytest (misc)"
|
||||
"Pytest (databricks)"
|
||||
"E2E Tests (shard 0/4)"
|
||||
@@ -35,6 +38,7 @@ REQUIRED=(
|
||||
)
|
||||
|
||||
ALLOW_SKIP=(
|
||||
"Docker build"
|
||||
"Pytest (runtime-harnesses)"
|
||||
"Pytest (runtime-policies)"
|
||||
"Pytest (runtime-core)"
|
||||
@@ -47,6 +51,8 @@ ALLOW_SKIP=(
|
||||
"Pytest (server-responses)"
|
||||
"Pytest (server-rest)"
|
||||
"Pytest (spec-llms)"
|
||||
"Pytest (runner-app)"
|
||||
"Pytest (stores)"
|
||||
"Pytest (misc)"
|
||||
"Pytest (databricks)"
|
||||
"E2E Tests (shard 0/4)"
|
||||
@@ -69,6 +75,7 @@ is_allow_skip() { printf '%s\n' "${ALLOW_SKIP[@]}" | grep -qxF "$1"; }
|
||||
# workflow is still queued or re-running.
|
||||
workflow_for() {
|
||||
case "$1" in
|
||||
"Docker build") echo "Docker build" ;;
|
||||
"Pytest ("*) echo "CI" ;;
|
||||
"E2E Tests (shard "*) echo "E2E Tests" ;;
|
||||
"E2E UI Tests (shard "*) echo "E2E UI Tests" ;;
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Daily Discord-watch rotation reminder.
|
||||
|
||||
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.
|
||||
|
||||
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 rotation order without Slack.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
# 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
|
||||
# is a band rather than an exact hour, which absorbs both daylight saving and
|
||||
# GitHub's frequently-delayed cron schedule — a run that fires a few hours late
|
||||
# still counts as that person's morning. The band starts at 05:00 (not
|
||||
# midnight) so a delayed *other* timezone's cron spilling past local midnight
|
||||
# isn't mistaken for this timezone's morning, which would double-ping.
|
||||
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 # 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], ...] = ()
|
||||
|
||||
|
||||
# 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 _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
|
||||
|
||||
|
||||
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 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 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 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
|
||||
|
||||
|
||||
class SlackPostError(RuntimeError):
|
||||
"""Raised when the Slack POST fails, without exposing the webhook URL."""
|
||||
|
||||
|
||||
def post_to_slack(webhook_url: str, person: Person) -> None:
|
||||
text = (
|
||||
f"<@{person.slack_id}> you're on *Discord watch* today \U0001f440 "
|
||||
f"— please keep an eye on the channel."
|
||||
)
|
||||
payload = json.dumps({"text": text}).encode()
|
||||
req = urllib.request.Request(
|
||||
webhook_url,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
# Catch and re-raise without the URL: urllib errors stringify the full
|
||||
# webhook URL, which must never reach the Actions log or error output.
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise SlackPostError(f"Slack returned HTTP {exc.code} {exc.reason}") from None
|
||||
except urllib.error.URLError as exc:
|
||||
raise SlackPostError(f"could not reach Slack: {exc.reason}") from None
|
||||
|
||||
|
||||
def _report_todays_assignees(now_utc: datetime.datetime) -> None:
|
||||
"""Log who's on watch for each timezone's current local date.
|
||||
|
||||
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 PEOPLE}):
|
||||
local = now_utc.astimezone(ZoneInfo(tz))
|
||||
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}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
print(f"Today's watch by timezone (as of {now_utc:%Y-%m-%d %H:%M UTC}):")
|
||||
_report_todays_assignees(now_utc)
|
||||
|
||||
person = whose_turn_now(now_utc)
|
||||
|
||||
if person is None:
|
||||
print(f"{now_utc:%Y-%m-%d %H:%M UTC}: nobody's on watch right now, nothing to do.")
|
||||
return
|
||||
|
||||
local = now_utc.astimezone(ZoneInfo(person.tz))
|
||||
webhook_url = os.environ.get("SLACK_WEBHOOK_URL")
|
||||
if not webhook_url:
|
||||
print(
|
||||
f"[dry run] Would ping {person.name} ({person.slack_id}) "
|
||||
f"— it's {local:%Y-%m-%d %H:%M} in {person.tz}. "
|
||||
f"Set SLACK_WEBHOOK_URL to post for real."
|
||||
)
|
||||
return
|
||||
|
||||
post_to_slack(webhook_url, person)
|
||||
print(f"Pinged {person.name} ({person.slack_id}) at {local:%Y-%m-%d %H:%M %Z}.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -210,16 +210,15 @@ module.exports = async ({ github, context, core }) => {
|
||||
}
|
||||
const loadOf = (u) => load.get(u.toLowerCase()) || 0;
|
||||
|
||||
// Helper: take the N most-preferred from a list. Sort key is (rank, load,
|
||||
// random): LLM area-fit rank first (lower = better; Infinity for unranked, so
|
||||
// an all-unranked list -- no rank file -- sorts purely by load, i.e. today's
|
||||
// behavior), then fewest open review requests, then a pre-rolled random value
|
||||
// to break any remaining same-rank-same-load tie. The `!==` guards avoid
|
||||
// subtracting two Infinities (which would be NaN).
|
||||
// Helper: take the N most-preferred from a list. Sort key is (load, rank,
|
||||
// random): fewest open review requests first so workload stays balanced;
|
||||
// LLM area-fit rank breaks ties within the same load bucket; a pre-rolled
|
||||
// random value breaks any remaining tie. The `!==` guards avoid subtracting
|
||||
// two Infinities (which would be NaN).
|
||||
const takeLowest = (list, n) => {
|
||||
const keyed = list.map((u) => ({ u, r: rankOf(u), l: loadOf(u), j: Math.random() }));
|
||||
keyed.sort((a, b) =>
|
||||
a.r !== b.r ? a.r - b.r : a.l !== b.l ? a.l - b.l : a.j - b.j
|
||||
a.l !== b.l ? a.l - b.l : a.r !== b.r ? a.r - b.r : a.j - b.j
|
||||
);
|
||||
return keyed.slice(0, n).map((x) => x.u);
|
||||
};
|
||||
|
||||
@@ -285,41 +285,39 @@ function assert(name, cond, detail) {
|
||||
assert("capped overflow is warned",
|
||||
r.warnings.some((w) => /capping push-down/.test(w)), JSON.stringify(r.warnings));
|
||||
|
||||
// 17. LLM ranking overrides load within the candidate pool: dhruv0811 has the
|
||||
// lowest load (would win on load alone), but the rank prefers dbczumar, an
|
||||
// inner owner -- so dbczumar is chosen.
|
||||
// 17. Load beats LLM rank: dhruv0811 has the lowest load (0) and wins even
|
||||
// though the rank prefers dbczumar (rank 0 but load 1).
|
||||
r = await run({
|
||||
files: ["omnigent/inner/foo.py"],
|
||||
load: { SabhyaC26: 5, TomeHirata: 4, dhruv0811: 0, dbczumar: 1 },
|
||||
rank: ["dbczumar", "TomeHirata", "SabhyaC26", "dhruv0811"],
|
||||
});
|
||||
assert("LLM rank beats load within the area pool",
|
||||
JSON.stringify(r.added) === JSON.stringify(["dbczumar"]), JSON.stringify(r));
|
||||
assert("load beats LLM rank within the area pool",
|
||||
JSON.stringify(r.added) === JSON.stringify(["dhruv0811"]), JSON.stringify(r));
|
||||
|
||||
// 18. Allowlist enforcement: a rank naming someone who does NOT own the touched
|
||||
// area (PattaraS is a maintainer + pool member, but not an inner owner) is
|
||||
// ignored for that entry; the ranking only reorders actual candidates, so
|
||||
// the next ranked inner owner (dbczumar) wins -- never PattaraS.
|
||||
// ignored; the ranking only reorders actual candidates. Load is primary, so
|
||||
// dhruv0811 (load 0) wins over dbczumar (load 1) -- never PattaraS.
|
||||
r = await run({
|
||||
files: ["omnigent/inner/foo.py"],
|
||||
load: { SabhyaC26: 5, TomeHirata: 4, dhruv0811: 0, dbczumar: 1, PattaraS: 0 },
|
||||
rank: ["PattaraS", "dbczumar", "TomeHirata", "SabhyaC26", "dhruv0811"],
|
||||
});
|
||||
assert("LLM rank cannot route outside the area owners",
|
||||
JSON.stringify(r.added) === JSON.stringify(["dbczumar"]) && !r.added.includes("PattaraS"),
|
||||
JSON.stringify(r.added) === JSON.stringify(["dhruv0811"]) && !r.added.includes("PattaraS"),
|
||||
JSON.stringify(r));
|
||||
|
||||
// 19. Unranked candidates (rank omits them) sort after ranked ones but still by
|
||||
// load: rank lists only SabhyaC26 (highest load); the rest are unranked, so
|
||||
// SabhyaC26 -- despite load 5 -- is preferred because a finite rank beats
|
||||
// Infinity. Confirms the rank-primary / load-secondary ordering.
|
||||
// 19. Load is primary even when only one candidate is ranked: rank lists only
|
||||
// SabhyaC26 (load 5); dhruv0811 is unranked but has load 0, so dhruv0811
|
||||
// wins. Confirms the load-primary / rank-secondary ordering.
|
||||
r = await run({
|
||||
files: ["omnigent/inner/foo.py"],
|
||||
load: { SabhyaC26: 5, TomeHirata: 4, dhruv0811: 0, dbczumar: 1 },
|
||||
rank: ["SabhyaC26"],
|
||||
});
|
||||
assert("a ranked high-load owner beats unranked low-load owners",
|
||||
JSON.stringify(r.added) === JSON.stringify(["SabhyaC26"]), JSON.stringify(r));
|
||||
assert("unranked low-load owner beats ranked high-load owner",
|
||||
JSON.stringify(r.added) === JSON.stringify(["dhruv0811"]), JSON.stringify(r));
|
||||
|
||||
// 20. Adoption still overrides the LLM rank: a linked-issue maintainer assignee
|
||||
// (TomeHirata) is adopted as reviewer even when the rank prefers someone
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
name: Benchmark
|
||||
|
||||
# Nightly run of the HTTP user-journey performance benchmark
|
||||
# (dev/benchmarks/omnigent). Seeds a sizeable corpus, boots a real server
|
||||
# against it, drives the journeys, and uploads the JSON report as an artifact.
|
||||
# Runs a backend matrix — SQLite (in-process) and Postgres (a service
|
||||
# container, matching prod's Lakebase/Postgres round-trip + pooling profile).
|
||||
# A workspace Databricks notebook pulls these artifacts via the GitHub API into
|
||||
# a Delta table for the trend dashboard (see dev/benchmarks/omnigent/README.md)
|
||||
# — so this workflow only produces artifacts; it never touches Databricks.
|
||||
#
|
||||
# Scheduled -> runs on the trusted default branch with the repo GITHUB_TOKEN;
|
||||
# it reads no PR-authored code. Also dispatchable for an ad-hoc run.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "37 7 * * *" # 07:37 UTC nightly (off-peak, off the :00 mark)
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
iterations:
|
||||
description: "Requests per run"
|
||||
required: false
|
||||
default: "100"
|
||||
runs:
|
||||
description: "Timed runs per journey"
|
||||
required: false
|
||||
default: "3"
|
||||
sessions:
|
||||
description: "Seeded sessions"
|
||||
required: false
|
||||
default: "5000"
|
||||
items_per_session:
|
||||
description: "Seeded items per session"
|
||||
required: false
|
||||
default: "200"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# No web SPA build during `uv sync` (setup.py _build_web_ui): this job never
|
||||
# serves the bundle, and the build otherwise times out on public npm.
|
||||
OMNIGENT_SKIP_WEB_UI: "true"
|
||||
UV_INDEX_URL: https://pypi.org/simple
|
||||
PIP_INDEX_URL: https://pypi.org/simple
|
||||
ITERATIONS: ${{ github.event_name == 'workflow_dispatch' && inputs.iterations || '100' }}
|
||||
RUNS: ${{ github.event_name == 'workflow_dispatch' && inputs.runs || '3' }}
|
||||
SESSIONS: ${{ github.event_name == 'workflow_dispatch' && inputs.sessions || '5000' }}
|
||||
ITEMS: ${{ github.event_name == 'workflow_dispatch' && inputs.items_per_session || '200' }}
|
||||
|
||||
concurrency:
|
||||
# Never cancel a scheduled run mid-flight (each is a distinct data point);
|
||||
# coalesce manual dispatches per ref.
|
||||
group: benchmark-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
jobs:
|
||||
benchmark:
|
||||
name: Run benchmark (${{ matrix.backend }})
|
||||
if: github.repository == 'omnigent-ai/omnigent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
backend: [sqlite, postgres]
|
||||
services:
|
||||
# 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:
|
||||
POSTGRES_PASSWORD: bench
|
||||
POSTGRES_DB: benchdb
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v3
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
# `databricks` extra carries psycopg[binary] for the Postgres backend.
|
||||
run: uv sync --extra dev --extra databricks
|
||||
|
||||
# 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 service is fresh each run so its DB is never cached).
|
||||
- name: Resolve DB target
|
||||
id: db
|
||||
run: |
|
||||
HEAD="$(uv run --no-sync dev/benchmarks/omnigent/seed.py --print-head)"
|
||||
if [[ "${{ matrix.backend }}" == "postgres" ]]; then
|
||||
echo "uri=postgresql+psycopg://postgres:bench@localhost:5432/benchdb" >> "$GITHUB_OUTPUT"
|
||||
echo "cache_path=" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "uri=sqlite:///$PWD/bench.db" >> "$GITHUB_OUTPUT"
|
||||
echo "cache_path=bench.db" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
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 postgres leg (empty path).
|
||||
- name: Restore seeded SQLite corpus
|
||||
if: matrix.backend == 'sqlite'
|
||||
id: seedcache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
|
||||
with:
|
||||
path: ${{ steps.db.outputs.cache_path }}
|
||||
key: ${{ steps.db.outputs.cache_key }}
|
||||
|
||||
- name: Seed corpus
|
||||
# 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 }}" \
|
||||
--sessions "$SESSIONS" --items-per-session "$ITEMS"
|
||||
|
||||
- name: Run benchmark
|
||||
run: |
|
||||
uv run --no-sync dev/benchmarks/omnigent/run.py \
|
||||
--database-uri "${{ steps.db.outputs.uri }}" \
|
||||
--iterations "$ITERATIONS" \
|
||||
--runs "$RUNS" \
|
||||
--output "benchmark-results-${{ matrix.backend }}.json"
|
||||
|
||||
- name: Upload benchmark results
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: benchmark-results-${{ matrix.backend }}-${{ github.run_id }}
|
||||
path: benchmark-results-${{ matrix.backend }}.json
|
||||
retention-days: 90
|
||||
if-no-files-found: warn
|
||||
+129
-7
@@ -2,10 +2,11 @@ name: CI
|
||||
|
||||
# Unit-test pytest matrix on every non-draft PR and on push to main. Tests are
|
||||
# split across directory-based matrix groups (runtime-*, server-*, inner-rest,
|
||||
# tools, repl-sdk, spec-llms, misc) so slow files don't bottleneck one runner;
|
||||
# the slowest groups use `--dist=worksteal` to fan tests out within a file. The
|
||||
# `misc` group is a catch-all so new top-level tests/<dir>/ are picked up
|
||||
# automatically. Draft PRs are skipped (ready_for_review re-fires the workflow).
|
||||
# tools, repl-sdk, spec-llms, runner-app, stores, misc) so slow files don't
|
||||
# bottleneck one runner; the slowest groups use `--dist=worksteal` to fan tests
|
||||
# out within a file. The `misc` group is a catch-all so new top-level
|
||||
# tests/<dir>/ are picked up automatically (it ignores the dirs that have their
|
||||
# own group). Draft PRs are skipped (ready_for_review re-fires the workflow).
|
||||
# A `coverage-report` job combines per-shard coverage for code-coverage.yml.
|
||||
|
||||
on:
|
||||
@@ -94,7 +95,21 @@ jobs:
|
||||
- group: integration-mock
|
||||
paths: tests/integration
|
||||
workers: "0"
|
||||
# Carved out of misc: runner + stores were ~68% of misc's cpu and
|
||||
# under loadfile a single 500s+ file (test_app_sessions_native) pinned
|
||||
# one worker and set the whole misc wall time. worksteal fans each
|
||||
# dir's tests across workers (biggest single test is ~40s / ~5s, so
|
||||
# the floor drops from ~500s to ~100s). Both dirs' conftests are
|
||||
# function-scoped, so splitting a file across workers is safe.
|
||||
- group: runner-app
|
||||
paths: tests/runner
|
||||
dist: worksteal
|
||||
- group: stores
|
||||
paths: tests/stores
|
||||
dist: worksteal
|
||||
# Catch-all so new top-level tests/<dir>/ are covered automatically.
|
||||
# worksteal keeps the biggest remaining file (the benchmark smoke
|
||||
# test, ~58s) from re-pinning one worker as this catch-all grows.
|
||||
- group: misc
|
||||
paths: >-
|
||||
tests
|
||||
@@ -112,6 +127,9 @@ jobs:
|
||||
--ignore=tests/spec
|
||||
--ignore=tests/llms
|
||||
--ignore=tests/codex_parity
|
||||
--ignore=tests/runner
|
||||
--ignore=tests/stores
|
||||
dist: worksteal
|
||||
# Databricks-coupled tests (Lakebase token engine, psycopg). This is
|
||||
# the only lane that installs the `databricks` extra; the
|
||||
# @pytest.mark.databricks marker keeps these tests off the lean lanes
|
||||
@@ -204,6 +222,100 @@ jobs:
|
||||
retention-days: 14
|
||||
include-hidden-files: true # the per-shard .coverage.<group> dotfile
|
||||
|
||||
stores-postgres:
|
||||
name: Pytest (stores-postgres)
|
||||
needs: gate
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_PASSWORD: omnigent
|
||||
POSTGRES_DB: omnigent_root
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --extra all --extra dev --extra databricks
|
||||
- name: Run store + DB tests against PostgreSQL
|
||||
env:
|
||||
OMNIGENT_TEST_DB_URI: postgresql+psycopg://postgres:omnigent@localhost:5432/omnigent_root
|
||||
run: |
|
||||
uv run pytest tests/stores tests/db \
|
||||
-m "not databricks" \
|
||||
-n 4 \
|
||||
--dist=loadfile \
|
||||
--timeout=300 \
|
||||
--junitxml=artifacts/pytest-stores-postgres.xml
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: pytest-stores-postgres-${{ github.run_id }}
|
||||
path: artifacts/
|
||||
retention-days: 14
|
||||
|
||||
stores-mysql:
|
||||
name: Pytest (stores-mysql)
|
||||
needs: gate
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: omnigent
|
||||
MYSQL_DATABASE: omnigent_root
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd "mysqladmin ping -h 127.0.0.1 -u root -pomnigent"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Install system MySQL client library
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -q libmysqlclient-dev
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --extra all --extra dev --extra databricks && uv pip install mysqlclient
|
||||
- name: Run store + DB tests against MySQL
|
||||
env:
|
||||
OMNIGENT_TEST_DB_URI: mysql+mysqldb://root:omnigent@127.0.0.1:3306/omnigent_root
|
||||
run: |
|
||||
uv run pytest tests/stores tests/db \
|
||||
-m "not databricks" \
|
||||
-n 4 \
|
||||
--dist=loadfile \
|
||||
--timeout=300 \
|
||||
--junitxml=artifacts/pytest-stores-mysql.xml
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: pytest-stores-mysql-${{ github.run_id }}
|
||||
path: artifacts/
|
||||
retention-days: 14
|
||||
|
||||
codex-parity:
|
||||
name: Pytest (codex-parity)
|
||||
needs: gate
|
||||
@@ -229,11 +341,20 @@ jobs:
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache Rust build
|
||||
- name: Capture Rust version
|
||||
id: rustc
|
||||
run: echo "version=$(rustc --version | tr ' ' '-')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The sidecar source is frozen and its deps are rev-pinned, so the binary is
|
||||
# a pure function of sidecar/** + the toolchain. Cache the built binary (not
|
||||
# the 1.6 GB target dir) and skip the ~3 min compile below on a hit; the key
|
||||
# self-invalidates when the source, Cargo.lock, or rustc changes.
|
||||
- name: Cache parity sidecar binary
|
||||
id: sidecar-cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
|
||||
with:
|
||||
path: .tmp-codex-parity-target
|
||||
key: codex-parity-sidecar-${{ runner.os }}-${{ hashFiles('tests/codex_parity/sidecar/Cargo.lock') }}
|
||||
path: .tmp-codex-parity-target/debug/codex-parity-sidecar
|
||||
key: codex-parity-bin-${{ runner.os }}-${{ steps.rustc.outputs.version }}-${{ hashFiles('tests/codex_parity/sidecar/**') }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -255,6 +376,7 @@ jobs:
|
||||
run: uv sync --locked --extra all --extra dev
|
||||
|
||||
- name: Build parity sidecar
|
||||
if: steps.sidecar-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cargo build \
|
||||
--manifest-path tests/codex_parity/sidecar/Cargo.toml \
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Discord watch rotation
|
||||
|
||||
# Wakes up only at the UTC times that are ~08:00 in an assignee's timezone.
|
||||
# Note: a single fixed UTC time can't track San Francisco's daylight saving,
|
||||
# so the SF ping lands at 08:00 in summer (PDT) and 07:00 in winter (PST).
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *" # 08:00 Asia/Singapore (UTC+8, no daylight saving)
|
||||
- cron: "0 15 * * *" # 08:00 SF in summer (PDT); 07:00 in winter (PST)
|
||||
workflow_dispatch: {} # manual "Run workflow" button for testing
|
||||
|
||||
# Only needs to check out the repo; nothing is written back.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Avoid overlapping runs if one is slow.
|
||||
concurrency:
|
||||
group: discord-watch-rotation
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
ping:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12" # for zoneinfo in the stdlib
|
||||
- name: Send rotation ping
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
run: python .github/scripts/rotation.py
|
||||
@@ -201,25 +201,30 @@ jobs:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
# Derive the per-minor docs staging branch from the runtime version. main
|
||||
# carries X.Y.Z.dev0, so 0.5.0.dev0 → "0.5-docs". All docs for the 0.5 line
|
||||
# (incl. patches) stage on this one branch until release publishes it.
|
||||
# Derive the per-minor docs staging branch and the release version from the
|
||||
# runtime version. main carries X.Y.Z.dev0, so 0.5.0.dev0 → branch "0.5-docs"
|
||||
# and label "v0.5.0". All docs for the 0.5 line (incl. patches) stage on the
|
||||
# one branch until release publishes it; the vX.Y.Z label lets maintainers
|
||||
# filter the staged PRs by the release they'll ship in.
|
||||
- name: Resolve docs branch
|
||||
id: docsbranch
|
||||
if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
minor="$(python3 - <<'PYEOF'
|
||||
import pathlib, re
|
||||
python3 - <<'PYEOF'
|
||||
import os, pathlib, re
|
||||
text = pathlib.Path("omnigent/version.py").read_text()
|
||||
m = re.search(r'VERSION\s*=\s*["\']([0-9]+)\.([0-9]+)', text)
|
||||
m = re.search(r'VERSION\s*=\s*["\']([0-9]+)\.([0-9]+)\.([0-9]+)', text)
|
||||
if not m:
|
||||
raise SystemExit("could not parse X.Y from omnigent/version.py")
|
||||
print(f"{m.group(1)}.{m.group(2)}")
|
||||
raise SystemExit("could not parse X.Y.Z from omnigent/version.py")
|
||||
major, minor, patch = m.groups()
|
||||
branch = f"{major}.{minor}-docs"
|
||||
version = f"v{major}.{minor}.{patch}"
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a") as fh:
|
||||
fh.write(f"branch={branch}\n")
|
||||
fh.write(f"version={version}\n")
|
||||
print(f"::notice::Docs stage on branch {branch} (release {version})")
|
||||
PYEOF
|
||||
)"
|
||||
echo "branch=${minor}-docs" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Docs stage on branch ${minor}-docs"
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
|
||||
@@ -593,6 +598,22 @@ jobs:
|
||||
m = re.search(r"<!--\s*DOC_DRAFT_SUMMARY\s*-->", raw)
|
||||
summary = raw[m.end():].strip() if m else "_(drafter produced edits but no summary)_"
|
||||
|
||||
# Title the docs PR after the DOCS change, not the source PR number (which
|
||||
# already appears in the body). Prefer the drafter's DOC_PR_TITLE line; fall
|
||||
# back to the source PR title, then to the old "document #N" form. LLM output
|
||||
# is untrusted, so sanitize: first line only, strip control chars, collapse
|
||||
# whitespace, drop a stray leading "docs:" (added below), and cap length.
|
||||
mt = re.search(r"^\s*DOC_PR_TITLE:\s*(.+?)\s*$", raw, re.MULTILINE)
|
||||
# Collapse whitespace (incl. tabs) to single spaces FIRST, so a stray tab
|
||||
# separates words rather than being stripped and joining them, then drop
|
||||
# any remaining non-whitespace control chars.
|
||||
draft_title = re.sub(r"\s+", " ", mt.group(1) if mt else "").strip()
|
||||
draft_title = re.sub(r"[\x00-\x1f\x7f]", "", draft_title)
|
||||
draft_title = re.sub(r"^docs:\s*", "", draft_title, flags=re.IGNORECASE).strip()[:60].strip()
|
||||
pr_title = f"docs: {draft_title or title or f'document {code}#{pr}'}"
|
||||
pathlib.Path("/tmp/site_pr_title.txt").write_text(pr_title)
|
||||
print(f"pr_title={pr_title!r}")
|
||||
|
||||
# Tag the maintainer who MERGED the PR — the author may be an outside
|
||||
# contributor with no site access, but a maintainer always merges. Fall back
|
||||
# to the author when there's no usable merger (e.g. a manual run on an
|
||||
@@ -635,9 +656,14 @@ jobs:
|
||||
PR_NUMBER: ${{ steps.plan.outputs.pr }}
|
||||
REVIEWER: ${{ steps.sitepr.outputs.reviewer }}
|
||||
DOCS_BRANCH: ${{ steps.docsbranch.outputs.branch }}
|
||||
VERSION_LABEL: ${{ steps.docsbranch.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BRANCH="auto/docs/pr-${PR_NUMBER}"
|
||||
# Descriptive PR/commit title from the sitepr step (drafter's DOC_PR_TITLE,
|
||||
# else the source PR title, else "docs: document #N"). The PR number lives
|
||||
# in the body, so it's kept out of the title.
|
||||
PR_TITLE="$(cat /tmp/site_pr_title.txt)"
|
||||
git config user.name "omnigent-ci[bot]"
|
||||
git config user.email "294685417+omnigent-ci[bot]@users.noreply.github.com"
|
||||
# Credentials are NOT persisted in .git/config (so the unsandboxed drafter
|
||||
@@ -680,22 +706,33 @@ jobs:
|
||||
|
||||
git checkout -B "$BRANCH"
|
||||
git add -A
|
||||
git commit -m "docs: document ${CODE_REPO}#${PR_NUMBER}"
|
||||
git commit -m "$PR_TITLE"
|
||||
# --force is safe here: the guard above ensured the branch carries only
|
||||
# bot commits.
|
||||
git push --force "$PUSH_URL" "$BRANCH"
|
||||
|
||||
# The vX.Y.Z label marks which release the staged docs will ship in, so
|
||||
# maintainers can filter the site PRs by release. Ensure it exists (with
|
||||
# automated-docs) before applying it below.
|
||||
gh label create automated-docs --repo "$SITE_REPO_SLUG" --color 0E8A16 \
|
||||
--description "Automated documentation update" 2>/dev/null || true
|
||||
gh label create "$VERSION_LABEL" --repo "$SITE_REPO_SLUG" --color FBCA04 \
|
||||
--description "Docs staged for the ${VERSION_LABEL} release" 2>/dev/null || true
|
||||
|
||||
EXISTING="$(gh pr list --repo "$SITE_REPO_SLUG" --head "$BRANCH" --state open \
|
||||
--json number --jq '.[0].number // empty' 2>/dev/null || true)"
|
||||
if [ -n "$EXISTING" ]; then
|
||||
gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" --body-file /tmp/site_pr_body.md || true
|
||||
# --add-label backfills PRs opened before the label existed; it's a no-op
|
||||
# when already present.
|
||||
gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" \
|
||||
--title "$PR_TITLE" \
|
||||
--add-label "automated-docs" --add-label "$VERSION_LABEL" \
|
||||
--body-file /tmp/site_pr_body.md || true
|
||||
echo "Updated site PR #$EXISTING."
|
||||
else
|
||||
gh label create automated-docs --repo "$SITE_REPO_SLUG" --color 0E8A16 \
|
||||
--description "Automated documentation update" 2>/dev/null || true
|
||||
if gh pr create --repo "$SITE_REPO_SLUG" --base "$DOCS_BRANCH" --head "$BRANCH" \
|
||||
--title "docs: document ${CODE_REPO}#${PR_NUMBER}" \
|
||||
--label automated-docs --body-file /tmp/site_pr_body.md; then
|
||||
--title "$PR_TITLE" \
|
||||
--label automated-docs --label "$VERSION_LABEL" --body-file /tmp/site_pr_body.md; then
|
||||
EXISTING="$(gh pr list --repo "$SITE_REPO_SLUG" --head "$BRANCH" --state open \
|
||||
--json number --jq '.[0].number // empty' 2>/dev/null || true)"
|
||||
echo "Opened site PR for $BRANCH."
|
||||
@@ -704,13 +741,17 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
# Always attempt the review request, decoupled from PR creation so a
|
||||
# non-addable reviewer can't fail the open. GitHub returns 422 for users it
|
||||
# can't add (non-collaborators / concealed org members); tolerate it — the
|
||||
# reviewer is also @-mentioned in the body as a durable fallback ping.
|
||||
# Always attempt the review request + assignment, decoupled from PR creation
|
||||
# so a non-addable reviewer can't fail the open. GitHub returns 422 for users
|
||||
# it can't add (non-collaborators / concealed org members); tolerate it — the
|
||||
# reviewer is also @-mentioned in the body as a durable fallback ping. The two
|
||||
# calls are independent so one failing doesn't skip the other. Assigning makes
|
||||
# the PR filterable by assignee from the site's PR list.
|
||||
if [ -n "${REVIEWER}" ] && [ -n "${EXISTING}" ]; then
|
||||
gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" --add-reviewer "${REVIEWER}" \
|
||||
|| echo "::notice::Could not request review from ${REVIEWER} (not addable); they're @-mentioned in the PR body."
|
||||
gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" --add-assignee "${REVIEWER}" \
|
||||
|| echo "::notice::Could not assign ${REVIEWER} (not addable); they're @-mentioned in the PR body."
|
||||
fi
|
||||
|
||||
- name: Note draft skipped (no site token)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Build-only Docker check for PRs. Compensates for retiring per-commit main
|
||||
# publishes (oss-publish-images.yml now builds on tags + nightly only): a broken
|
||||
# Dockerfile / lockfile / frontend build would otherwise not surface until the
|
||||
# nightly rebuild or a release. Builds the server image single-arch (linux/amd64)
|
||||
# with the GHA layer cache and runs a `omnigent --help` CLI smoke. It never pushes.
|
||||
#
|
||||
# Scope: the server target exercises the shared builder stage (Python deps +
|
||||
# web SPA build) that all four published variants inherit, so it catches the
|
||||
# common breakage without paying for the host/openshell/kubernetes variants or
|
||||
# the emulated arm64 leg.
|
||||
#
|
||||
# Blocking merge-gate check: "Docker build" is in the REQUIRED list in
|
||||
# .github/scripts/merge-ready/required.sh. Because of the paths filter below it
|
||||
# can legitimately be absent (a PR touching nothing in the image), so it is also
|
||||
# in ALLOW_SKIP with a workflow_for() arm, and this workflow's name is in
|
||||
# merge-ready.yml's workflow_run list so the gate re-evaluates when it completes.
|
||||
name: Docker build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
# Only build when something that lands in the image changes. Mirrors the
|
||||
# publish workflow's former push paths (web/** IS included here — the image
|
||||
# bakes the SPA, so a web-only PR can still break the build).
|
||||
paths:
|
||||
- 'deploy/docker/Dockerfile'
|
||||
- 'deploy/docker/entrypoint.py'
|
||||
- 'omnigent/**'
|
||||
- 'web/**'
|
||||
- 'sdks/**'
|
||||
- 'pyproject.toml'
|
||||
- 'setup.py'
|
||||
- 'uv.lock'
|
||||
- 'web/package-lock.json'
|
||||
- '.github/workflows/docker-build.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: docker-build-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Security precondition gate (security-gate.yml): untrusted PRs wait for the
|
||||
# scan before the build runs on their code; trusted authors pass through.
|
||||
gate:
|
||||
uses: ./.github/workflows/security-gate.yml
|
||||
|
||||
build:
|
||||
name: Docker build
|
||||
needs: gate
|
||||
# Draft PRs skip the build (ready_for_review re-fires the workflow), matching
|
||||
# the pytest job in ci.yml.
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
# Single-arch (amd64) build, no push. load: true imports the result into
|
||||
# the runner's Docker so the smoke step below can run it. Shares the same
|
||||
# type=gha cache the publish workflow writes, so warm PRs reuse layers.
|
||||
- name: Build server image (amd64, no push)
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: deploy/docker/Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
tags: omnigent-server:pr-${{ github.event.pull_request.number || github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
|
||||
- name: CLI smoke
|
||||
run: docker run --rm omnigent-server:pr-${{ github.event.pull_request.number || github.sha }} omnigent --help
|
||||
@@ -111,16 +111,23 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
# Pin the toolchain for a stable cache fingerprint, key on the sidecar
|
||||
# Cargo.lock. A warm hit reuses every dep and only relinks the workspace
|
||||
# crate (~40s); a cold miss is the full ~7min compile (rare -- the lock
|
||||
# is near-static). Same key as ci.yml's codex-parity job, so they share.
|
||||
- name: Cache Rust build
|
||||
- name: Capture Rust version
|
||||
id: rustc
|
||||
run: echo "version=$(rustc --version | tr ' ' '-')" >> "$GITHUB_OUTPUT"
|
||||
# The sidecar source is frozen and its deps are rev-pinned, so the binary
|
||||
# is a pure function of sidecar/** + the toolchain. Cache the built binary
|
||||
# (not the 1.6 GB target dir) and skip the ~7 min compile below on a hit;
|
||||
# the key self-invalidates when the source, Cargo.lock, or rustc changes.
|
||||
# Same key as ci.yml's codex-parity job -- ci.yml runs on push to main and
|
||||
# populates the main-scoped cache that this PR-only workflow restores from.
|
||||
- name: Cache parity sidecar binary
|
||||
id: sidecar-cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
|
||||
with:
|
||||
path: .tmp-codex-parity-target
|
||||
key: codex-parity-sidecar-${{ runner.os }}-${{ hashFiles('tests/codex_parity/sidecar/Cargo.lock') }}
|
||||
path: .tmp-codex-parity-target/debug/codex-parity-sidecar
|
||||
key: codex-parity-bin-${{ runner.os }}-${{ steps.rustc.outputs.version }}-${{ hashFiles('tests/codex_parity/sidecar/**') }}
|
||||
- name: Build parity sidecar
|
||||
if: steps.sidecar-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cargo build \
|
||||
--manifest-path tests/codex_parity/sidecar/Cargo.toml \
|
||||
|
||||
@@ -19,6 +19,9 @@ on:
|
||||
schedule:
|
||||
- cron: "0 9 * * *"
|
||||
pull_request:
|
||||
# labeled/unlabeled: kept for the skip-security-scan recovery path
|
||||
# (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/**']
|
||||
workflow_dispatch:
|
||||
@@ -34,8 +37,9 @@ on:
|
||||
|
||||
concurrency:
|
||||
# PRs key by number, dispatch by branch (so re-runs cancel); schedule keys
|
||||
# by SHA so each merge to `main` gets its own run.
|
||||
group: e2e-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.inputs.branch || github.sha }}
|
||||
# by SHA so each merge to `main` gets its own run. Label events append the
|
||||
# label name so they get an isolated slot and never cancel a code-push run.
|
||||
group: e2e-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.inputs.branch || github.sha }}-${{ (github.event.action == 'labeled' || github.event.action == 'unlabeled') && github.event.label.name || 'run' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
@@ -54,11 +58,14 @@ env:
|
||||
jobs:
|
||||
# Security gate: untrusted PRs wait on the deterministic scan
|
||||
# (security-gate.yml); trusted authors and non-PR events pass instantly.
|
||||
# Skip when the automerge label is applied/removed -- safe to short-circuit
|
||||
# here because every non-gate job is transitively downstream of gate, so
|
||||
# no skipped check-run can overwrite an existing result on this SHA.
|
||||
# Short-circuit for label events that aren't skip-security-scan (e.g.
|
||||
# automerge): those run in their own isolated concurrency slot (above) and
|
||||
# don't need the full suite — just exit fast.
|
||||
gate:
|
||||
if: github.event.label.name != 'automerge'
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
(github.event.action != 'labeled' && github.event.action != 'unlabeled') ||
|
||||
github.event.label.name == 'skip-security-scan'
|
||||
uses: ./.github/workflows/security-gate.yml
|
||||
|
||||
# Shard matrix (e2e-shard-matrix.sh, shared with e2e-ui.yml). Fork PRs run by
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Electron Build
|
||||
|
||||
# Manually-triggered build of the Electron desktop shell (web/electron) for
|
||||
# Linux and Windows. Each platform packages on its own native runner —
|
||||
# electron-builder does not reliably cross-compile installers — and uploads the
|
||||
# installers as downloadable workflow artifacts. Unsigned: no signing creds are
|
||||
# wired here, so `CSC_IDENTITY_AUTO_DISCOVERY=false` forces an unsigned build
|
||||
# rather than failing when a cert is absent. No publishing / release upload.
|
||||
#
|
||||
# Run it from the Actions tab (Run workflow). macOS is intentionally omitted —
|
||||
# its signed/notarized build lives elsewhere.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Branch, tag, or SHA to build."
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
# One build per ref: back-to-back manual dispatches on the same ref queue
|
||||
# instead of running concurrently (keyed on ref only — including run_id would
|
||||
# make every run its own group, defeating the serialization).
|
||||
group: electron-build-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.platform }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
# Keep building the other platform even if one fails, so a Windows-only
|
||||
# break still yields the Linux installers (and vice versa).
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
build-script: build:linux
|
||||
- os: windows-latest
|
||||
platform: win
|
||||
build-script: build:win
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
# Node 22.x per web/electron/README.md ("Prerequisites").
|
||||
node-version: "22"
|
||||
cache-dependency-path: web/electron/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: web/electron
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Build ${{ matrix.platform }} app
|
||||
working-directory: web/electron
|
||||
env:
|
||||
# No signing credentials in CI: force an unsigned build instead of
|
||||
# letting electron-builder fail hunting for a certificate.
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
# electron-builder downloads Electron/tooling from GitHub; the token
|
||||
# lifts the anonymous rate limit that otherwise flakes downloads.
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: npm run ${{ matrix.build-script }} -- --publish never
|
||||
|
||||
- name: Upload installers
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: omnigent-desktop-${{ matrix.platform }}
|
||||
# Ship only the distributables, not electron-builder's unpacked
|
||||
# intermediates (dist/linux-unpacked, dist/win-unpacked, blockmaps).
|
||||
path: |
|
||||
web/electron/dist/*.AppImage
|
||||
web/electron/dist/*.deb
|
||||
web/electron/dist/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -503,10 +503,10 @@ jobs:
|
||||
maintainer_assigned=true
|
||||
fi
|
||||
|
||||
# Otherwise, assign an owner for P0/P1 issues: the LLM's top-ranked area
|
||||
# owner, breaking ties by open-assigned-issue load (fairness). Symmetric
|
||||
# with the PR reviewer path (rank primary, load secondary). Skipped if
|
||||
# the maintainer-author was already assigned above.
|
||||
# Otherwise, assign an owner for P0/P1 issues: the least-loaded area
|
||||
# owner, with LLM rank as a tiebreaker (load primary, rank secondary).
|
||||
# Symmetric with the PR reviewer path. Skipped if the maintainer-author
|
||||
# was already assigned above.
|
||||
priority=$(jq -r '.priority // empty' /tmp/triage_result.json)
|
||||
if [ "$maintainer_assigned" = "false" ] && { [ "$priority" = "P0-critical" ] || [ "$priority" = "P1-high" ]; }; then
|
||||
# Open-issue load per candidate (fewest assigned open issues wins ties).
|
||||
@@ -533,12 +533,12 @@ jobs:
|
||||
if a.get("login"):
|
||||
load[a["login"]] += 1
|
||||
|
||||
# Sort by (rank, load, login): LLM rank first, then fewest open issues,
|
||||
# then a stable alphabetical tie-break (deterministic, unlike a random
|
||||
# one — matches the previous round-robin's determinism guarantee).
|
||||
# Sort by (load, rank, login): fewest open assigned issues first so
|
||||
# the workload stays balanced; LLM rank breaks ties within the same
|
||||
# load bucket; alphabetical login is the final deterministic tiebreak.
|
||||
candidates = sorted(
|
||||
candidates,
|
||||
key=lambda u: (rank_of.get(u, float("inf")), load[u], u),
|
||||
key=lambda u: (load[u], rank_of.get(u, float("inf")), u),
|
||||
)
|
||||
assignee = candidates[0] if candidates else ""
|
||||
if assignee:
|
||||
|
||||
@@ -102,6 +102,23 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ktlint is invoked by the android-ktlint-* pre-commit hooks. The wrapper
|
||||
# script (web/android/bin/ktlint.sh) exits 0 if ktlint is absent, so we
|
||||
# install it here before pre-commit runs to ensure the check is enforced.
|
||||
# The binary is verified against a pinned SHA-256 so a corrupted or spoofed
|
||||
# download is caught before the binary is made executable.
|
||||
- name: Install ktlint
|
||||
env:
|
||||
KTLINT_VERSION: "1.8.0"
|
||||
KTLINT_SHA256: "a3fd620207d5c40da6ca789b95e7f823c54e854b7fade7f613e91096a3706d75"
|
||||
run: |
|
||||
curl -sSLf \
|
||||
"https://github.com/ktlint/ktlint/releases/download/${KTLINT_VERSION}/ktlint" \
|
||||
-o /tmp/ktlint
|
||||
echo "${KTLINT_SHA256} /tmp/ktlint" | sha256sum -c
|
||||
chmod +x /tmp/ktlint
|
||||
sudo mv /tmp/ktlint /usr/local/bin/ktlint
|
||||
|
||||
- name: Run formatting, lint, and typing checks
|
||||
run: uv run pre-commit run --all-files --show-diff-on-failure
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
workflow_run:
|
||||
workflows: [PR Template, CI, Lint, E2E UI Tests, E2E Tests, Integration Tests]
|
||||
workflows: [PR Template, CI, Lint, Docker build, E2E UI Tests, E2E Tests, Integration Tests]
|
||||
types: [completed]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
@@ -12,10 +12,8 @@
|
||||
# `pip install omnigent` resolves to. Pre-releases never move it.
|
||||
# :latest-rc the highest version OVERALL, max(release, rc) — the newest
|
||||
# thing tagged, pre-release or not.
|
||||
# :latest-dev the most recent main build (bleeding edge); moves on every
|
||||
# qualifying main commit.
|
||||
# :latest-nightly the most recent main build as of the daily cron; retagged
|
||||
# from :latest-dev once a day (no rebuild).
|
||||
# :latest-nightly the most recent nightly main build (bleeding edge); moves
|
||||
# once a day when the scheduled build rebuilds main HEAD.
|
||||
# Ordering for :latest / :latest-rc uses PEP 440 (1.2.3rc1 < 1.2.3), which
|
||||
# `sort -V` gets wrong, so the max is computed with .github/scripts/
|
||||
# oss-publish-images/maxver.py (Python `packaging`).
|
||||
@@ -25,23 +23,15 @@
|
||||
name: Publish images (public)
|
||||
|
||||
on:
|
||||
# Release builds only — every v* tag push publishes the immutable version pin
|
||||
# and moves the floating release tags. Per-commit main builds were retired in
|
||||
# favour of the nightly rebuild below; PRs get a build-only check (docker-build.yml)
|
||||
# so a broken image is caught before merge without a push.
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
# Only rebuild when something that lands in the image changes.
|
||||
paths:
|
||||
- 'deploy/docker/Dockerfile'
|
||||
- 'deploy/docker/entrypoint.py'
|
||||
- 'omnigent/**'
|
||||
- 'web/**'
|
||||
- 'sdks/**'
|
||||
- 'pyproject.toml'
|
||||
- 'setup.py'
|
||||
- 'uv.lock'
|
||||
- 'web/package-lock.json'
|
||||
- '.github/workflows/oss-publish-images.yml'
|
||||
# Daily nightly promotion (07:00 UTC). Retags the current :latest-dev as
|
||||
# :latest-nightly — handled by promote-nightly, not a rebuild.
|
||||
# Nightly rebuild of main HEAD (07:00 UTC): the build-and-push job publishes
|
||||
# :sha-<short> + :latest-nightly. This is what keeps bleeding-edge ~1 day
|
||||
# fresh now that main commits no longer each trigger a build.
|
||||
schedule:
|
||||
- cron: '0 7 * * *'
|
||||
workflow_dispatch:
|
||||
@@ -50,10 +40,6 @@ on:
|
||||
description: 'Also move :latest to this build (manual release of latest). Off by default.'
|
||||
type: boolean
|
||||
default: false
|
||||
force_nightly:
|
||||
description: 'Promote :latest-dev -> :latest-nightly now (runs only the nightly job). Off by default.'
|
||||
type: boolean
|
||||
default: false
|
||||
reconcile_floating:
|
||||
description: 'Repoint :latest and :latest-rc onto the correct existing version images (no rebuild). Runs only the reconcile job. Off by default.'
|
||||
type: boolean
|
||||
@@ -73,10 +59,11 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # push the image to GHCR via GITHUB_TOKEN
|
||||
# Gated to this repository; inert in forks and mirrors. Skip the (re)build
|
||||
# on schedule, force_nightly, and reconcile_floating dispatches — those only
|
||||
# drive the promote-nightly / reconcile-floating jobs.
|
||||
if: github.repository == 'omnigent-ai/omnigent' && github.event_name != 'schedule' && !inputs.force_nightly && !inputs.reconcile_floating
|
||||
# Gated to this repository; inert in forks and mirrors. Runs on tag pushes,
|
||||
# the nightly schedule (rebuild of main HEAD), and bump_latest dispatches.
|
||||
# Skipped on reconcile_floating dispatches — that only drives the
|
||||
# reconcile-floating retag job.
|
||||
if: github.repository == 'omnigent-ai/omnigent' && !inputs.reconcile_floating
|
||||
runs-on: ubuntu-latest
|
||||
# Multi-arch: the linux/arm64 leg cross-builds under QEMU emulation on this
|
||||
# amd64 runner, which roughly doubles the host-image build time (emulated
|
||||
@@ -124,23 +111,26 @@ jobs:
|
||||
IMAGE="ghcr.io/omnigent-ai/omnigent-server"
|
||||
HOST_IMAGE="ghcr.io/omnigent-ai/omnigent-host"
|
||||
OPENSHELL_IMAGE="ghcr.io/omnigent-ai/omnigent-server-openshell"
|
||||
KUBERNETES_IMAGE="ghcr.io/omnigent-ai/omnigent-server-kubernetes"
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
|
||||
# Immutable per-commit pin, always.
|
||||
TAGS="${IMAGE}:sha-${SHORT_SHA}"
|
||||
HOST_TAGS="${HOST_IMAGE}:sha-${SHORT_SHA}"
|
||||
OPENSHELL_TAGS="${OPENSHELL_IMAGE}:sha-${SHORT_SHA}"
|
||||
KUBERNETES_TAGS="${KUBERNETES_IMAGE}:sha-${SHORT_SHA}"
|
||||
|
||||
# Append a floating/version tag to all images.
|
||||
add_tag() {
|
||||
TAGS="${TAGS},${IMAGE}:$1"
|
||||
HOST_TAGS="${HOST_TAGS},${HOST_IMAGE}:$1"
|
||||
OPENSHELL_TAGS="${OPENSHELL_TAGS},${OPENSHELL_IMAGE}:$1"
|
||||
KUBERNETES_TAGS="${KUBERNETES_TAGS},${KUBERNETES_IMAGE}:$1"
|
||||
}
|
||||
|
||||
# Every qualifying main commit moves :latest-dev (bleeding edge).
|
||||
# The nightly rebuild of main moves :latest-nightly (bleeding edge).
|
||||
if [ "${GH_REF}" = "refs/heads/main" ]; then
|
||||
add_tag "latest-dev"
|
||||
add_tag "latest-nightly"
|
||||
fi
|
||||
|
||||
if [[ "${GH_REF}" == refs/tags/v* ]]; then
|
||||
@@ -175,6 +165,7 @@ jobs:
|
||||
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
||||
echo "host_tags=${HOST_TAGS}" >> "$GITHUB_OUTPUT"
|
||||
echo "openshell_tags=${OPENSHELL_TAGS}" >> "$GITHUB_OUTPUT"
|
||||
echo "kubernetes_tags=${KUBERNETES_TAGS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# No build-args: the Dockerfile ARGs default to public registries.
|
||||
# Multi-arch: each tag publishes as a manifest list spanning amd64 + arm64,
|
||||
@@ -234,10 +225,32 @@ jobs:
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
sbom: true
|
||||
|
||||
# Kubernetes server variant: the default server image plus the kubernetes
|
||||
# client extra (OMNIGENT_EXTRAS=kubernetes), so `sandbox.provider:
|
||||
# kubernetes` works without a self-built image. Used by the
|
||||
# deploy/kubernetes/overlays/sandbox-runners kustomize overlay. Reuses
|
||||
# the shared builder-stage layers from the gha cache.
|
||||
- name: Build and push kubernetes server image
|
||||
id: build-kubernetes
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: deploy/docker/Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.tags.outputs.kubernetes_tags }}
|
||||
build-args: |
|
||||
OMNIGENT_EXTRAS=kubernetes
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
sbom: true
|
||||
outputs:
|
||||
server-digest: ${{ steps.build-server.outputs.digest }}
|
||||
host-digest: ${{ steps.build-host.outputs.digest }}
|
||||
openshell-digest: ${{ steps.build-openshell.outputs.digest }}
|
||||
kubernetes-digest: ${{ steps.build-kubernetes.outputs.digest }}
|
||||
|
||||
generate-sbom:
|
||||
# Runs in a separate job with read-only permissions so the Syft
|
||||
@@ -281,6 +294,13 @@ jobs:
|
||||
-o cyclonedx-json=openshell-sbom.cdx.json \
|
||||
-o spdx-json=openshell-sbom.spdx.json
|
||||
|
||||
- name: Generate kubernetes server SBOM
|
||||
run: |
|
||||
set -euo pipefail
|
||||
syft "ghcr.io/omnigent-ai/omnigent-server-kubernetes@${{ needs.build-and-push.outputs.kubernetes-digest }}" \
|
||||
-o cyclonedx-json=kubernetes-sbom.cdx.json \
|
||||
-o spdx-json=kubernetes-sbom.spdx.json
|
||||
|
||||
- name: Upload SBOMs
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
@@ -292,45 +312,10 @@ jobs:
|
||||
host-sbom.spdx.json
|
||||
openshell-sbom.cdx.json
|
||||
openshell-sbom.spdx.json
|
||||
kubernetes-sbom.cdx.json
|
||||
kubernetes-sbom.spdx.json
|
||||
retention-days: 90
|
||||
|
||||
promote-nightly:
|
||||
# Daily cron (or a manual force_nightly dispatch): move :latest-nightly to
|
||||
# the current main build by retagging :latest-dev with `crane tag`
|
||||
# (digest-preserving, no rebuild).
|
||||
if: github.repository == 'omnigent-ai/omnigent' && (github.event_name == 'schedule' || inputs.force_nightly)
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # retag within GHCR via GITHUB_TOKEN
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Set up crane
|
||||
uses: imjasonh/setup-crane@59c71e96a00b28651f10369ba3359a6d730740a0 # v0.6
|
||||
with:
|
||||
version: v0.21.6
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Promote latest-dev -> latest-nightly
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# crane tag points a new tag at an EXISTING manifest digest without
|
||||
# re-serializing it, so :latest-nightly keeps :latest-dev's exact digest.
|
||||
for img in ghcr.io/omnigent-ai/omnigent-server ghcr.io/omnigent-ai/omnigent-host ghcr.io/omnigent-ai/omnigent-server-openshell; do
|
||||
if crane digest "${img}:latest-dev" >/dev/null 2>&1; then
|
||||
crane tag "${img}:latest-dev" latest-nightly
|
||||
echo "promoted ${img}:latest-dev -> :latest-nightly ($(crane digest "${img}:latest-nightly"))"
|
||||
else
|
||||
echo "::warning::${img}:latest-dev not found yet; skipping nightly promotion"
|
||||
fi
|
||||
done
|
||||
|
||||
reconcile-floating:
|
||||
# Manual reconcile (workflow_dispatch with reconcile_floating=true): repoint
|
||||
# :latest and :latest-rc onto the correct EXISTING version images, computed
|
||||
@@ -393,7 +378,7 @@ jobs:
|
||||
fi
|
||||
}
|
||||
|
||||
for img in ghcr.io/omnigent-ai/omnigent-server ghcr.io/omnigent-ai/omnigent-host ghcr.io/omnigent-ai/omnigent-server-openshell; do
|
||||
for img in ghcr.io/omnigent-ai/omnigent-server ghcr.io/omnigent-ai/omnigent-host ghcr.io/omnigent-ai/omnigent-server-openshell ghcr.io/omnigent-ai/omnigent-server-kubernetes; do
|
||||
retag "${img}" "latest-rc" "${RC_TAG}"
|
||||
retag "${img}" "latest" "${LATEST_TAG}"
|
||||
done
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Reviewer SLA Test
|
||||
|
||||
# Offline unit test for the SLA sweep logic: runs review-sla.test.js (mocked
|
||||
# GitHub client, real .github/MAINTAINER; ownership pinned to a frozen fixture).
|
||||
# Triggers only when the sweep, its test, or the pool files it reads change. Runs
|
||||
# on `pull_request` (PR head checkout) so it tests the PR's own version. No
|
||||
# secrets, no network.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/review-sla.js
|
||||
- .github/workflows/review-sla.test.js
|
||||
- .github/workflows/review-sla.yml
|
||||
- .github/MAINTAINER
|
||||
- .github/areas.json
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: review-sla-test-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run reviewer-SLA unit test
|
||||
run: node .github/workflows/review-sla.test.js
|
||||
@@ -0,0 +1,340 @@
|
||||
// Reviewer SLA sweep: nudge + escalate open PRs and issues that a MAINTAINER has
|
||||
// been sitting on for more than SLA_DAYS *working* days without replying.
|
||||
//
|
||||
// Runs on a schedule from the trusted default branch (see review-sla.yml), so it
|
||||
// reads no PR-authored code and just talks to the issues/PRs API. For each open,
|
||||
// non-draft item:
|
||||
// - PRs: the "assigned person" is any maintainer in requested_reviewers (GitHub
|
||||
// drops them from that list the moment they submit a review, so being in it
|
||||
// means "still owes a review"). The clock starts at their latest
|
||||
// `review_requested` event (fallback: PR opened). If >= SLA_DAYS working days
|
||||
// have elapsed AND they've posted no comment or review since, the SLA is
|
||||
// breached: re-ping them in one comment and add ONE second reviewer (lowest
|
||||
// open-review load among the area owners in .github/areas.json, mirrored as
|
||||
// an assignee like auto-assign-reviewer.js does).
|
||||
// - Issues: the "assigned person" is any maintainer assignee; clock starts at
|
||||
// their latest `assigned` event. Breach -> re-ping + add one second assignee
|
||||
// from the owners of the area(s) whose comp:* label the issue carries.
|
||||
//
|
||||
// Ownership comes from .github/areas.json -- the single source of truth shared
|
||||
// with auto-assign-reviewer.js and issue-triage.yml (it replaced the old
|
||||
// .github/reviewers + .github/ISSUE_ASSIGNEES files). `owners_paused` is ignored.
|
||||
//
|
||||
// "Working days" = weekdays (Mon-Fri) in UTC. Reply = ANY comment or review by the
|
||||
// assignee since the clock started.
|
||||
//
|
||||
// Escalate-once, two independent guards so the bot never spams:
|
||||
// 1. a one-shot LABEL, and
|
||||
// 2. the MARKER hidden in the reminder comment -- checked as a fallback so that
|
||||
// even if the label write fails after the comment lands, the next sweep still
|
||||
// sees the marker and skips.
|
||||
// The second reviewer/assignee is added FIRST (best-effort); the comment is then
|
||||
// worded to match what actually happened (so it can't claim "Adding @X" when the
|
||||
// add 422'd), and the label is written last. If the comment itself fails nothing
|
||||
// user-visible was posted, so we skip the label and let the next sweep retry.
|
||||
//
|
||||
// ponytail: one escalation per item. Per-reviewer re-escalation or a weekly
|
||||
// re-ping would need per-nudge timestamp state instead of the label+marker pair --
|
||||
// add that only if a single nudge proves too weak.
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
const SLA_DAYS = 5; // working days
|
||||
const LABEL = "review-sla-escalated";
|
||||
const MARKER = "<!-- review-sla-bot -->"; // idempotency fallback if the label write fails
|
||||
const CANONICAL_REPO = "omnigent-ai/omnigent";
|
||||
// Max escalations per sweep. Bounds the day-one blast against an existing stale
|
||||
// backlog (and any future surge): the backlog drains a chunk per weekday instead
|
||||
// of nudging everything at once. PRs are processed before issues.
|
||||
// ponytail: single global cap; split into per-kind caps if issue nudges starving
|
||||
// behind a large PR backlog ever matters.
|
||||
const MAX_ESCALATIONS_PER_RUN = 30;
|
||||
|
||||
// --- Pure helpers (exported for the offline test; no network) --------------
|
||||
|
||||
// Weekdays strictly after `from`'s date, through `to`'s date, in UTC. So a review
|
||||
// requested on a Monday first counts as 5 working days the following Monday.
|
||||
// ponytail: weekends only, no holiday calendar -- add one if the SLA needs it.
|
||||
function workingDaysBetween(from, to) {
|
||||
const cur = new Date(from);
|
||||
cur.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(to);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
let count = 0;
|
||||
while (cur < end) {
|
||||
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||
const d = cur.getUTCDay();
|
||||
if (d !== 0 && d !== 6) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Latest ISO timestamp per (lowercased) login for a given timeline event type.
|
||||
function latestByUser(timeline, eventName, getLogin) {
|
||||
const out = {};
|
||||
for (const e of timeline || []) {
|
||||
if (e.event !== eventName) continue;
|
||||
const login = getLogin(e);
|
||||
if (!login || !e.created_at) continue;
|
||||
const lc = login.toLowerCase();
|
||||
if (!out[lc] || new Date(e.created_at) > new Date(out[lc])) out[lc] = e.created_at;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Did `login` post any comment/review after `sinceIso`?
|
||||
function repliedSince(login, sinceIso, comments, reviews, reviewComments) {
|
||||
const since = new Date(sinceIso).getTime();
|
||||
const lc = login.toLowerCase();
|
||||
const by = (u) => (u || "").toLowerCase() === lc;
|
||||
const after = (t) => t && new Date(t).getTime() > since;
|
||||
return (
|
||||
(comments || []).some((c) => by(c.user && c.user.login) && after(c.created_at)) ||
|
||||
(reviews || []).some((r) => by(r.user && r.user.login) && after(r.submitted_at)) ||
|
||||
(reviewComments || []).some((rc) => by(rc.user && rc.user.login) && after(rc.created_at))
|
||||
);
|
||||
}
|
||||
|
||||
// Have we already posted a reminder here? (idempotency fallback for a failed label)
|
||||
function alreadyNudged(comments) {
|
||||
return (comments || []).some((c) => (c.body || "").includes(MARKER));
|
||||
}
|
||||
|
||||
// Breached maintainer targets for one item, given the reply signals. Shared by the
|
||||
// PR and issue paths (issues pass [] for reviews/reviewComments).
|
||||
function breachedTargets({ targets, clockStartByUser, openedAt, now, comments, reviews, reviewComments }) {
|
||||
const out = [];
|
||||
for (const t of targets) {
|
||||
// Fallback to openedAt when there's no explicit request/assign event for
|
||||
// this login (e.g. a CODEOWNERS/team expansion, or a timeline pagination
|
||||
// edge). That can over-count elapsed time slightly -- acceptable, and never
|
||||
// fires for the normal auto-assigned path which always emits the event.
|
||||
const since = clockStartByUser[t.toLowerCase()] || openedAt;
|
||||
if (workingDaysBetween(since, now) < SLA_DAYS) continue;
|
||||
if (repliedSince(t, since, comments, reviews, reviewComments)) continue;
|
||||
out.push(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Parse .github/areas.json (same shape auto-assign-reviewer.js reads) into:
|
||||
// rules - [{ prefix, owners }] in document order (last match wins per file)
|
||||
// pool - Map lc->original of every owner (the full candidate set)
|
||||
// labelOwners - Map "comp:x" -> Set of owners, for routing an issue by its label
|
||||
// `owners_paused` is intentionally ignored. `text` is injectable for tests.
|
||||
function parseAreas(text) {
|
||||
const areas = JSON.parse(text).areas || [];
|
||||
const rules = [];
|
||||
const pool = new Map();
|
||||
const labelOwners = new Map();
|
||||
for (const area of areas) {
|
||||
const owners = area.owners || [];
|
||||
owners.forEach((o) => pool.set(o.toLowerCase(), o));
|
||||
for (const p of area.paths || []) rules.push({ prefix: p.replace(/^\//, ""), owners });
|
||||
if (area.label) {
|
||||
const set = labelOwners.get(area.label) || new Set();
|
||||
owners.forEach((o) => set.add(o));
|
||||
labelOwners.set(area.label, set);
|
||||
}
|
||||
}
|
||||
return { rules, pool, labelOwners };
|
||||
}
|
||||
|
||||
// Count currently-open review requests per (lc) login -- the stateless fairness
|
||||
// signal auto-assign-reviewer.js also uses.
|
||||
function buildLoad(openPRs) {
|
||||
const load = new Map();
|
||||
for (const p of openPRs)
|
||||
for (const r of p.requested_reviewers || []) {
|
||||
const l = (r.login || "").toLowerCase();
|
||||
load.set(l, (load.get(l) || 0) + 1);
|
||||
}
|
||||
return load;
|
||||
}
|
||||
|
||||
// Pick the lowest-load of a candidate list, random tie-break within a load tier.
|
||||
function lowestLoad(candidates, load) {
|
||||
if (!candidates.length) return null;
|
||||
const loadOf = (u) => load.get(u.toLowerCase()) || 0;
|
||||
const byTier = {};
|
||||
for (const u of candidates) (byTier[loadOf(u)] ||= []).push(u);
|
||||
const lowest = byTier[Math.min(...Object.keys(byTier).map(Number))];
|
||||
return lowest[Math.floor(Math.random() * lowest.length)];
|
||||
}
|
||||
|
||||
// One lowest-load area owner for the PR's files, else lowest from the full pool;
|
||||
// never anyone already on the PR.
|
||||
function pickSecondReviewer({ files, rules, pool, load, exclude }) {
|
||||
const areaOwners = new Map();
|
||||
for (const f of files) {
|
||||
let match = null;
|
||||
for (const r of rules) if (f.startsWith(r.prefix)) match = r; // last wins
|
||||
if (match) match.owners.forEach((o) => areaOwners.set(o.toLowerCase(), o));
|
||||
}
|
||||
const base = areaOwners.size ? areaOwners : pool;
|
||||
return lowestLoad([...base.values()].filter((u) => !exclude.has(u.toLowerCase())), load);
|
||||
}
|
||||
|
||||
// One second assignee from the owners of the issue's comp:* area(s), else the full
|
||||
// pool; never anyone already assigned.
|
||||
// ponytail: tie-break reuses the PR open-review `load` -- a proxy for issues (there
|
||||
// is no per-assignee open-issue count), so this only approximates issue fairness.
|
||||
// Tally open-issue assignee counts here if that starts to matter.
|
||||
function pickSecondAssignee({ labels, labelOwners, pool, load, exclude }) {
|
||||
const owners = new Set();
|
||||
for (const l of labels) for (const o of labelOwners.get(l) || []) owners.add(o);
|
||||
const base = owners.size ? owners : new Set(pool.values());
|
||||
return lowestLoad([...base].filter((u) => !exclude.has(u.toLowerCase())), load);
|
||||
}
|
||||
|
||||
// --- Orchestrator ----------------------------------------------------------
|
||||
|
||||
async function run({ github, context, core }) {
|
||||
const { owner, repo } = context.repo;
|
||||
if (`${owner}/${repo}` !== CANONICAL_REPO) {
|
||||
core.info(`Not ${CANONICAL_REPO}; skipping.`);
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
|
||||
const maintainers = new Set(
|
||||
fs.readFileSync(".github/MAINTAINER", "utf8")
|
||||
.split("\n").map((l) => l.replace(/#.*/, "").trim().toLowerCase()).filter(Boolean)
|
||||
);
|
||||
// REVIEWER_AREAS_FILE lets the unit test pin a fixture; defaults to the real file.
|
||||
const areasFile = process.env.REVIEWER_AREAS_FILE || ".github/areas.json";
|
||||
const { rules, pool, labelOwners } = parseAreas(fs.readFileSync(areasFile, "utf8"));
|
||||
|
||||
const hasLabel = (item) => (item.labels || []).some((l) => (l.name || l) === LABEL);
|
||||
const escalated = [];
|
||||
const capReached = () => escalated.length >= MAX_ESCALATIONS_PER_RUN;
|
||||
|
||||
// Escalate one item once. Add the second reviewer/assignee FIRST (best-effort,
|
||||
// returns the login it actually added or null), so the comment states the true
|
||||
// outcome; then post the marked comment; then lock the LABEL. If the comment
|
||||
// fails, nothing was posted -> skip the label and retry next sweep.
|
||||
const escalateOnce = async (number, breached, kind, addSecond, secondCandidate) => {
|
||||
let added = null;
|
||||
if (secondCandidate) {
|
||||
try {
|
||||
added = (await addSecond()) ? secondCandidate : null;
|
||||
} catch (e) {
|
||||
core.warning(`#${number}: could not add second ${kind} @${secondCandidate}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
const noun = kind === "reviewer" ? "review" : "a response";
|
||||
const body =
|
||||
`${MARKER}\n⏰ **${kind === "reviewer" ? "Reviewer" : "Response"} SLA** — this ${kind === "reviewer" ? "PR" : "issue"} ` +
|
||||
`has been awaiting ${noun} from ${breached.map((u) => "@" + u).join(", ")} for more than ${SLA_DAYS} working days.` +
|
||||
(added ? ` Adding @${added} as a second ${kind}.` : "");
|
||||
try {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: number, body });
|
||||
} catch (e) {
|
||||
core.warning(`#${number}: reminder comment failed, will retry next run: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: [LABEL] });
|
||||
} catch (e) {
|
||||
core.warning(`#${number}: could not add ${LABEL} label (marker still guards re-nudge): ${e.message}`);
|
||||
}
|
||||
escalated.push(`${kind === "reviewer" ? "PR" : "issue"} #${number} (re-pinged ${breached.join(", ")}${added ? `, +@${added}` : ""})`);
|
||||
};
|
||||
|
||||
// ----- PRs: awaiting a maintainer's review -----
|
||||
const openPRs = await github.paginate(github.rest.pulls.list, { owner, repo, state: "open", per_page: 100 });
|
||||
const load = buildLoad(openPRs);
|
||||
// Count each second reviewer/assignee we add during THIS sweep against the load
|
||||
// map, so successive picks rotate instead of dogpiling the current lowest-load
|
||||
// maintainer -- without it, one sweep hands nearly every escalation to one person.
|
||||
const bumpLoad = (u) => load.set(u.toLowerCase(), (load.get(u.toLowerCase()) || 0) + 1);
|
||||
|
||||
for (const pr of openPRs) {
|
||||
if (capReached()) break;
|
||||
if (pr.draft || hasLabel(pr)) continue;
|
||||
const targets = (pr.requested_reviewers || []).map((r) => r.login).filter((l) => maintainers.has(l.toLowerCase()));
|
||||
if (!targets.length) continue;
|
||||
|
||||
const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, { owner, repo, issue_number: pr.number, per_page: 100 });
|
||||
const requestedAt = latestByUser(timeline, "review_requested", (e) => e.requested_reviewer && e.requested_reviewer.login);
|
||||
|
||||
// Cheap staleness prefilter before fetching reply signals.
|
||||
const stale = targets.filter((t) => workingDaysBetween(requestedAt[t.toLowerCase()] || pr.created_at, now) >= SLA_DAYS);
|
||||
if (!stale.length) continue;
|
||||
|
||||
const [comments, reviews, reviewComments] = await Promise.all([
|
||||
github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 }),
|
||||
github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: pr.number, per_page: 100 }),
|
||||
github.paginate(github.rest.pulls.listReviewComments, { owner, repo, pull_number: pr.number, per_page: 100 }),
|
||||
]);
|
||||
if (alreadyNudged(comments)) continue; // label may have failed to write; marker still guards
|
||||
const breached = breachedTargets({
|
||||
targets: stale, clockStartByUser: requestedAt, openedAt: pr.created_at, now, comments, reviews, reviewComments,
|
||||
});
|
||||
if (!breached.length) continue;
|
||||
|
||||
const files = (await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number, per_page: 100 })).map((f) => f.filename);
|
||||
const onPr = new Set(
|
||||
[pr.user && pr.user.login, ...targets, ...(pr.assignees || []).map((a) => a.login), ...(pr.requested_reviewers || []).map((r) => r.login)]
|
||||
.filter(Boolean).map((s) => s.toLowerCase())
|
||||
);
|
||||
const second = pickSecondReviewer({ files, rules, pool, load, exclude: onPr });
|
||||
|
||||
await escalateOnce(pr.number, breached, "reviewer", async () => {
|
||||
await github.rest.pulls.requestReviewers({ owner, repo, pull_number: pr.number, reviewers: [second] });
|
||||
// Mirror as assignee for UI filterability, matching auto-assign-reviewer.js.
|
||||
await github.rest.issues.addAssignees({ owner, repo, issue_number: pr.number, assignees: [second] });
|
||||
bumpLoad(second);
|
||||
return true;
|
||||
}, second);
|
||||
}
|
||||
|
||||
// ----- Issues: awaiting a maintainer assignee -----
|
||||
const openIssues = await github.paginate(github.rest.issues.listForRepo, { owner, repo, state: "open", per_page: 100 });
|
||||
for (const issue of openIssues) {
|
||||
if (capReached()) break;
|
||||
if (issue.pull_request || hasLabel(issue)) continue; // listForRepo also returns PRs
|
||||
const targets = (issue.assignees || []).map((a) => a.login).filter((l) => maintainers.has(l.toLowerCase()));
|
||||
if (!targets.length) continue;
|
||||
|
||||
const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, { owner, repo, issue_number: issue.number, per_page: 100 });
|
||||
const assignedAt = latestByUser(timeline, "assigned", (e) => e.assignee && e.assignee.login);
|
||||
|
||||
const stale = targets.filter((t) => workingDaysBetween(assignedAt[t.toLowerCase()] || issue.created_at, now) >= SLA_DAYS);
|
||||
if (!stale.length) continue;
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: issue.number, per_page: 100 });
|
||||
if (alreadyNudged(comments)) continue;
|
||||
const breached = breachedTargets({
|
||||
targets: stale, clockStartByUser: assignedAt, openedAt: issue.created_at, now, comments, reviews: [], reviewComments: [],
|
||||
});
|
||||
if (!breached.length) continue;
|
||||
|
||||
const labels = (issue.labels || []).map((l) => l.name || l).filter((n) => n.startsWith("comp:"));
|
||||
const onIssue = new Set((issue.assignees || []).map((a) => a.login.toLowerCase()));
|
||||
const second = pickSecondAssignee({ labels, labelOwners, pool, load, exclude: onIssue });
|
||||
|
||||
await escalateOnce(issue.number, breached, "assignee", async () => {
|
||||
await github.rest.issues.addAssignees({ owner, repo, issue_number: issue.number, assignees: [second] });
|
||||
bumpLoad(second);
|
||||
return true;
|
||||
}, second);
|
||||
}
|
||||
|
||||
core.info(escalated.length ? `Escalated ${escalated.length}: ${escalated.join("; ")}.` : "No SLA breaches; nothing to escalate.");
|
||||
}
|
||||
|
||||
module.exports = run;
|
||||
// Exported for the offline unit test.
|
||||
module.exports.workingDaysBetween = workingDaysBetween;
|
||||
module.exports.latestByUser = latestByUser;
|
||||
module.exports.repliedSince = repliedSince;
|
||||
module.exports.alreadyNudged = alreadyNudged;
|
||||
module.exports.breachedTargets = breachedTargets;
|
||||
module.exports.parseAreas = parseAreas;
|
||||
module.exports.pickSecondReviewer = pickSecondReviewer;
|
||||
module.exports.pickSecondAssignee = pickSecondAssignee;
|
||||
module.exports.SLA_DAYS = SLA_DAYS;
|
||||
module.exports.LABEL = LABEL;
|
||||
module.exports.MARKER = MARKER;
|
||||
module.exports.MAX_ESCALATIONS_PER_RUN = MAX_ESCALATIONS_PER_RUN;
|
||||
@@ -0,0 +1,237 @@
|
||||
// Offline unit test for review-sla.js -- exercises the pure decision helpers and
|
||||
// one end-to-end orchestration of each path against a mocked GitHub client. No
|
||||
// network. cwd must be the repo root (the orchestrator reads the real
|
||||
// .github/MAINTAINER; ownership is pinned to a frozen fixture via
|
||||
// REVIEWER_AREAS_FILE so the test doesn't churn when .github/areas.json changes).
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const fs = require("fs");
|
||||
const script = require(path.resolve(".github/workflows/review-sla.js"));
|
||||
|
||||
// Frozen area fixture: stable owners the orchestration assertions can pin to.
|
||||
const FIXTURE = {
|
||||
areas: [
|
||||
{ key: "inner", label: "comp:harnesses", paths: ["omnigent/inner/"], owners: ["ownerA", "ownerB", "ownerC"] },
|
||||
{ key: "web", label: "comp:web-ui", paths: ["web/"], owners: ["webX", "webY"] },
|
||||
],
|
||||
};
|
||||
const FIXTURE_PATH = path.join(os.tmpdir(), "review-sla-areas.fixture.json");
|
||||
fs.writeFileSync(FIXTURE_PATH, JSON.stringify(FIXTURE));
|
||||
process.env.REVIEWER_AREAS_FILE = FIXTURE_PATH;
|
||||
|
||||
function assert(name, cond, detail) {
|
||||
console.log(`${cond ? "PASS" : "FAIL"} ${name}${detail ? " -- " + detail : ""}`);
|
||||
if (!cond) process.exitCode = 1;
|
||||
}
|
||||
|
||||
const daysAgoIso = (n) => new Date(Date.now() - n * 86400000).toISOString();
|
||||
|
||||
// Mocked GitHub client. `canned` maps a list-endpoint tag -> the array it returns
|
||||
// through github.paginate; writes are recorded in `sink`. `failRequestReviewers`
|
||||
// makes pulls.requestReviewers throw, to exercise the partial-failure path.
|
||||
function mkGithub(canned, sink, opts = {}) {
|
||||
const list = (tag) => { const f = async () => {}; f._tag = tag; return f; };
|
||||
return {
|
||||
paginate: async (fn) => canned[fn._tag] || [],
|
||||
rest: {
|
||||
pulls: {
|
||||
list: list("openPRs"),
|
||||
listReviews: list("reviews"),
|
||||
listReviewComments: list("reviewComments"),
|
||||
listFiles: list("files"),
|
||||
requestReviewers: async (a) => {
|
||||
if (opts.failRequestReviewers) throw new Error("HTTP 422: reviewer is not a collaborator");
|
||||
sink.requested.push(...a.reviewers);
|
||||
},
|
||||
},
|
||||
issues: {
|
||||
listForRepo: list("openIssues"),
|
||||
listEventsForTimeline: list("timeline"),
|
||||
listComments: list("comments"),
|
||||
createComment: async (a) => sink.comments.push(a),
|
||||
addAssignees: async (a) => sink.assigned.push(...a.assignees),
|
||||
addLabels: async (a) => sink.labels.push(...a.labels),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runOrch(canned, opts) {
|
||||
const sink = { comments: [], requested: [], assigned: [], labels: [], warnings: [] };
|
||||
const core = { info: () => {}, warning: (m) => sink.warnings.push(m) };
|
||||
const context = { repo: { owner: "omnigent-ai", repo: "omnigent" } };
|
||||
await script({ github: mkGithub(canned, sink, opts), context, core });
|
||||
return sink;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// ---- workingDaysBetween (2026-01-05 is a Monday, 01-12 the next Monday) ----
|
||||
const wdb = script.workingDaysBetween;
|
||||
assert("same day -> 0", wdb("2026-01-05", "2026-01-05") === 0);
|
||||
assert("Mon -> next Mon (7 cal days) -> 5 working days", wdb("2026-01-05", "2026-01-12") === 5, String(wdb("2026-01-05", "2026-01-12")));
|
||||
assert("Fri -> Mon spans a weekend -> 1", wdb("2026-01-09", "2026-01-12") === 1, String(wdb("2026-01-09", "2026-01-12")));
|
||||
assert("Sat -> Sun -> 0", wdb("2026-01-10", "2026-01-11") === 0);
|
||||
|
||||
// ---- latestByUser ----
|
||||
const tl = [
|
||||
{ event: "review_requested", requested_reviewer: { login: "Alice" }, created_at: "2026-01-01T00:00:00Z" },
|
||||
{ event: "review_requested", requested_reviewer: { login: "Alice" }, created_at: "2026-01-03T00:00:00Z" },
|
||||
{ event: "assigned", assignee: { login: "Bob" }, created_at: "2026-01-02T00:00:00Z" },
|
||||
];
|
||||
const rq = script.latestByUser(tl, "review_requested", (e) => e.requested_reviewer && e.requested_reviewer.login);
|
||||
assert("latestByUser keeps the newer event", rq.alice === "2026-01-03T00:00:00Z", JSON.stringify(rq));
|
||||
assert("latestByUser ignores other event types", !("bob" in rq));
|
||||
|
||||
// ---- repliedSince ----
|
||||
const since = "2026-01-01T00:00:00Z";
|
||||
assert("comment after -> replied",
|
||||
script.repliedSince("alice", since, [{ user: { login: "Alice" }, created_at: "2026-01-02T00:00:00Z" }], [], []) === true);
|
||||
assert("comment before -> not replied",
|
||||
script.repliedSince("alice", since, [{ user: { login: "Alice" }, created_at: "2025-12-31T00:00:00Z" }], [], []) === false);
|
||||
assert("review after -> replied",
|
||||
script.repliedSince("alice", since, [], [{ user: { login: "alice" }, submitted_at: "2026-01-05T00:00:00Z" }], []) === true);
|
||||
assert("someone else's comment -> not replied",
|
||||
script.repliedSince("alice", since, [{ user: { login: "Bob" }, created_at: "2026-01-09T00:00:00Z" }], [], []) === false);
|
||||
|
||||
// ---- alreadyNudged (marker fallback) ----
|
||||
assert("alreadyNudged: marker present -> true", script.alreadyNudged([{ body: "hi " + script.MARKER }]) === true);
|
||||
assert("alreadyNudged: no marker -> false", script.alreadyNudged([{ body: "just a normal comment" }]) === false);
|
||||
|
||||
// ---- breachedTargets ----
|
||||
const now = new Date();
|
||||
const b1 = script.breachedTargets({
|
||||
targets: ["Alice"], clockStartByUser: { alice: daysAgoIso(14) }, openedAt: daysAgoIso(30), now,
|
||||
comments: [], reviews: [], reviewComments: [],
|
||||
});
|
||||
assert("stale + silent -> breached", JSON.stringify(b1) === JSON.stringify(["Alice"]), JSON.stringify(b1));
|
||||
const b2 = script.breachedTargets({
|
||||
targets: ["Alice"], clockStartByUser: { alice: daysAgoIso(1) }, openedAt: daysAgoIso(1), now,
|
||||
comments: [], reviews: [], reviewComments: [],
|
||||
});
|
||||
assert("within SLA -> not breached", b2.length === 0, JSON.stringify(b2));
|
||||
const b3 = script.breachedTargets({
|
||||
targets: ["Alice"], clockStartByUser: { alice: daysAgoIso(14) }, openedAt: daysAgoIso(30), now,
|
||||
comments: [{ user: { login: "Alice" }, created_at: daysAgoIso(1) }], reviews: [], reviewComments: [],
|
||||
});
|
||||
assert("stale but replied -> not breached", b3.length === 0, JSON.stringify(b3));
|
||||
|
||||
// ---- parseAreas ----
|
||||
const { rules, pool, labelOwners } = script.parseAreas(JSON.stringify(FIXTURE));
|
||||
assert("parseAreas: rules preserve prefixes", rules.some((r) => r.prefix === "omnigent/inner/") && rules.some((r) => r.prefix === "web/"), JSON.stringify(rules));
|
||||
assert("parseAreas: pool unions all owners", ["ownera", "ownerb", "ownerc", "webx", "weby"].every((o) => pool.has(o)), JSON.stringify([...pool.keys()]));
|
||||
assert("parseAreas: labelOwners maps comp:* -> owners", [...(labelOwners.get("comp:web-ui") || [])].sort().join(",") === "webX,webY", JSON.stringify([...(labelOwners.get("comp:web-ui") || [])]));
|
||||
|
||||
// ---- pickSecondReviewer ----
|
||||
const srMembers = script.pickSecondReviewer({
|
||||
files: ["omnigent/inner/foo.py"], rules, pool, load: new Map(),
|
||||
exclude: new Set(["ownera"]),
|
||||
});
|
||||
assert("second reviewer is an inner owner, excluding those on the PR",
|
||||
["ownerb", "ownerc"].includes((srMembers || "").toLowerCase()), String(srMembers));
|
||||
const srLoad = script.pickSecondReviewer({
|
||||
files: ["omnigent/inner/foo.py"], rules, pool,
|
||||
load: new Map([["ownera", 5], ["ownerb", 5], ["ownerc", 0]]),
|
||||
exclude: new Set(),
|
||||
});
|
||||
assert("lowest-load owner wins the tie-break", (srLoad || "").toLowerCase() === "ownerc", String(srLoad));
|
||||
const srFallback = script.pickSecondReviewer({
|
||||
files: ["README.md"], rules, pool, load: new Map(), exclude: new Set(),
|
||||
});
|
||||
assert("unowned path -> falls back to the full pool", pool.has((srFallback || "").toLowerCase()), String(srFallback));
|
||||
|
||||
// ---- pickSecondAssignee ----
|
||||
const saMatch = script.pickSecondAssignee({
|
||||
labels: ["comp:web-ui"], labelOwners, pool, load: new Map(), exclude: new Set(["webx"]),
|
||||
});
|
||||
assert("second assignee comes from the label's owners, excluding the current one",
|
||||
(saMatch || "").toLowerCase() === "weby", String(saMatch));
|
||||
const saFallback = script.pickSecondAssignee({
|
||||
labels: [], labelOwners, pool, load: new Map(), exclude: new Set(),
|
||||
});
|
||||
assert("no comp label -> falls back to the full pool", pool.has((saFallback || "").toLowerCase()), String(saFallback));
|
||||
|
||||
// ---- orchestration: a stale, silent PR gets nudged + a 2nd reviewer + label --
|
||||
const stalePR = {
|
||||
number: 7, draft: false, labels: [], user: { login: "someexternaldev" },
|
||||
created_at: daysAgoIso(14), requested_reviewers: [{ login: "dhruv0811" }], assignees: [{ login: "dhruv0811" }],
|
||||
};
|
||||
let s = await runOrch({
|
||||
openPRs: [stalePR], openIssues: [], timeline: [], comments: [], reviews: [], reviewComments: [],
|
||||
files: [{ filename: "omnigent/inner/foo.py" }],
|
||||
});
|
||||
assert("stale PR: one reminder comment posted", s.comments.length === 1 && s.comments[0].issue_number === 7, JSON.stringify(s.comments));
|
||||
assert("stale PR: comment re-pings the assigned reviewer", /@dhruv0811/.test(s.comments[0].body), s.comments[0] && s.comments[0].body);
|
||||
assert("stale PR: a second reviewer is requested from the area owners",
|
||||
s.requested.length === 1 && ["ownera", "ownerb", "ownerc"].includes(s.requested[0].toLowerCase()), JSON.stringify(s.requested));
|
||||
assert("stale PR: second reviewer mirrored as assignee", JSON.stringify(s.assigned) === JSON.stringify(s.requested), JSON.stringify(s.assigned));
|
||||
assert("stale PR: comment names exactly the reviewer that was added",
|
||||
new RegExp(`Adding @${s.requested[0]} as a second reviewer`).test(s.comments[0].body), s.comments[0] && s.comments[0].body);
|
||||
assert("stale PR: comment carries the idempotency marker", s.comments[0].body.includes(script.MARKER), s.comments[0] && s.comments[0].body);
|
||||
assert("stale PR: labelled once", JSON.stringify(s.labels) === JSON.stringify([script.LABEL]), JSON.stringify(s.labels));
|
||||
|
||||
// ---- orchestration: partial failure -- requestReviewers throws --
|
||||
// add-first ordering means the comment must NOT claim a 2nd reviewer that failed
|
||||
// to attach, yet the item is still labelled so it won't be re-nudged tomorrow.
|
||||
s = await runOrch({
|
||||
openPRs: [stalePR], openIssues: [], timeline: [], comments: [], reviews: [], reviewComments: [],
|
||||
files: [{ filename: "omnigent/inner/foo.py" }],
|
||||
}, { failRequestReviewers: true });
|
||||
assert("partial failure: reminder comment still posted", s.comments.length === 1, JSON.stringify(s.comments));
|
||||
assert("partial failure: comment does NOT over-claim a second reviewer", !/second reviewer/.test(s.comments[0].body), s.comments[0] && s.comments[0].body);
|
||||
assert("partial failure: no reviewer was actually requested", s.requested.length === 0, JSON.stringify(s.requested));
|
||||
assert("partial failure: still labelled (won't re-nudge next run)", JSON.stringify(s.labels) === JSON.stringify([script.LABEL]), JSON.stringify(s.labels));
|
||||
assert("partial failure: the reviewer-add error is warned, not fatal", s.warnings.some((w) => /could not add second reviewer/.test(w)), JSON.stringify(s.warnings));
|
||||
|
||||
// ---- orchestration: marker fallback -- prior nudge exists but the label didn't --
|
||||
s = await runOrch({
|
||||
openPRs: [stalePR], openIssues: [], timeline: [], reviews: [], reviewComments: [],
|
||||
files: [{ filename: "omnigent/inner/foo.py" }],
|
||||
comments: [{ user: { login: "omnigent-ci" }, body: script.MARKER + "\nearlier nudge", created_at: daysAgoIso(2) }],
|
||||
});
|
||||
assert("marker fallback: an already-nudged PR (marker present, no label) is skipped",
|
||||
s.comments.length === 0 && s.labels.length === 0, JSON.stringify(s));
|
||||
|
||||
// ---- orchestration: already-labelled PR is left alone (one-shot) ----
|
||||
s = await runOrch({ openPRs: [{ ...stalePR, labels: [{ name: script.LABEL }] }], openIssues: [], files: [] });
|
||||
assert("already-escalated PR is skipped", s.comments.length === 0 && s.labels.length === 0, JSON.stringify(s));
|
||||
|
||||
// ---- orchestration: a fresh PR (within SLA) is left alone ----
|
||||
s = await runOrch({ openPRs: [{ ...stalePR, created_at: daysAgoIso(1) }], openIssues: [], timeline: [], files: [] });
|
||||
assert("fresh PR is not escalated", s.comments.length === 0, JSON.stringify(s));
|
||||
|
||||
// ---- orchestration: a PR whose reviewer already commented is left alone ----
|
||||
s = await runOrch({
|
||||
openPRs: [stalePR], openIssues: [], timeline: [], reviews: [], reviewComments: [], files: [],
|
||||
comments: [{ user: { login: "dhruv0811" }, created_at: daysAgoIso(1) }],
|
||||
});
|
||||
assert("PR with a recent reply is not escalated", s.comments.length === 0, JSON.stringify(s));
|
||||
|
||||
// ---- orchestration: a stale, silent issue gets nudged + a 2nd assignee + label --
|
||||
const staleIssue = {
|
||||
number: 9, labels: [{ name: "comp:web-ui" }], created_at: daysAgoIso(14), assignees: [{ login: "hzub" }],
|
||||
};
|
||||
s = await runOrch({ openPRs: [], openIssues: [staleIssue], timeline: [], comments: [] });
|
||||
assert("stale issue: one reminder comment posted", s.comments.length === 1 && s.comments[0].issue_number === 9, JSON.stringify(s.comments));
|
||||
assert("stale issue: re-pings the assignee", /@hzub/.test(s.comments[0].body), s.comments[0] && s.comments[0].body);
|
||||
assert("stale issue: a second assignee from the label's owners", ["webx", "weby"].includes((s.assigned[0] || "").toLowerCase()), JSON.stringify(s.assigned));
|
||||
assert("stale issue: labelled once", JSON.stringify(s.labels) === JSON.stringify([script.LABEL]), JSON.stringify(s.labels));
|
||||
|
||||
// ---- orchestration: a real PR object (listForRepo) is not double-swept as an issue --
|
||||
s = await runOrch({ openPRs: [], openIssues: [{ ...staleIssue, pull_request: {} }], timeline: [], comments: [] });
|
||||
assert("PR returned by listForRepo is skipped in the issue sweep", s.comments.length === 0, JSON.stringify(s));
|
||||
|
||||
// ---- orchestration: per-run cap + in-sweep load spread ----
|
||||
// Feed more stale PRs than the cap. Expect exactly MAX escalations, and the
|
||||
// second reviewer rotates across all 3 inner owners rather than dogpiling the
|
||||
// one lowest-load maintainer (regression for the live-data concentration bug).
|
||||
const MAX = script.MAX_ESCALATIONS_PER_RUN;
|
||||
const manyStale = Array.from({ length: MAX + 5 }, (_, i) => ({ ...stalePR, number: 3000 + i }));
|
||||
s = await runOrch({
|
||||
openPRs: manyStale, openIssues: [], timeline: [], comments: [], reviews: [], reviewComments: [],
|
||||
files: [{ filename: "omnigent/inner/foo.py" }],
|
||||
});
|
||||
assert("cap: escalations stop at MAX_ESCALATIONS_PER_RUN", s.comments.length === MAX, `${s.comments.length} vs ${MAX}`);
|
||||
assert("cap: labels capped to match", s.labels.length === MAX, String(s.labels.length));
|
||||
assert("load spread: second reviewer rotates across all 3 inner owners (not dogpiled on one)",
|
||||
new Set(s.requested.map((u) => u.toLowerCase())).size === 3, JSON.stringify([...new Set(s.requested)]));
|
||||
})();
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Reviewer SLA
|
||||
|
||||
# Daily (weekday) sweep that enforces a 5-working-day reviewer SLA: any open PR
|
||||
# awaiting review from a maintainer -- or open issue awaiting a maintainer
|
||||
# assignee -- with no reply in 5 working days gets the assignee re-pinged in a
|
||||
# comment plus a second reviewer (PR) / second assignee (issue), then a one-shot
|
||||
# `review-sla-escalated` label so it's never nudged twice. All logic + safety
|
||||
# notes live in review-sla.js (offline unit test: review-sla.test.js).
|
||||
#
|
||||
# Scheduled -> runs on the trusted default branch with the repo GITHUB_TOKEN; it
|
||||
# reads no PR-authored code, only .github/ config + the issues/PRs API.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 8 * * 1-5" # 08:00 UTC, Mon-Fri (weekday SLA -> no weekend pings)
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: review-sla
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
if: github.repository == 'omnigent-ai/omnigent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
# Job-level permissions REPLACE the workflow-level block, so restate read.
|
||||
contents: read
|
||||
pull-requests: write # comment + request the second reviewer
|
||||
issues: write # comment + assign + label
|
||||
steps:
|
||||
# Trusted default branch, .github only (config the script reads). Never PR head.
|
||||
- name: Check out .github
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github
|
||||
persist-credentials: false
|
||||
- name: Sweep open PRs + issues for SLA breaches
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const script = require('./.github/workflows/review-sla.js');
|
||||
await script({ github, context, core });
|
||||
@@ -51,6 +51,10 @@ run-omnigents.sh
|
||||
artifacts/
|
||||
.tmp-codex-parity-target/
|
||||
|
||||
# omnidev (dev pod supervisor) Rust build output. Pod state lives outside the
|
||||
# repo under ~/.cache/omnidev/, so only the build dir needs ignoring.
|
||||
dev/omnidev/target/
|
||||
|
||||
# Playwright test run output (screenshots, traces, videos).
|
||||
test-results/
|
||||
|
||||
|
||||
@@ -46,6 +46,26 @@ repos:
|
||||
# fights the tooling).
|
||||
exclude: ^(omnigent/server/static/web-ui/assets/|web/.*\.xcassets/|web/.*\.icon/)
|
||||
|
||||
# Android Kotlin formatting + linting via ktlint (config:
|
||||
# web/android/.editorconfig). The wrapper no-ops when ktlint is absent,
|
||||
# so local machines without ktlint installed skip cleanly. CI installs
|
||||
# ktlint before running pre-commit, so the check is enforced there.
|
||||
# Install locally with `brew install ktlint` (macOS) or download from
|
||||
# https://github.com/pinterest/ktlint/releases.
|
||||
- id: android-ktlint-format
|
||||
name: android ktlint format
|
||||
language: system
|
||||
entry: web/android/bin/ktlint.sh --format
|
||||
files: ^web/android/.*\.kts?$
|
||||
exclude: ^web/android/(build|\.gradle)/
|
||||
|
||||
- id: android-ktlint-check
|
||||
name: android ktlint check
|
||||
language: system
|
||||
entry: web/android/bin/ktlint.sh
|
||||
files: ^web/android/.*\.kts?$
|
||||
exclude: ^web/android/(build|\.gradle)/
|
||||
|
||||
# iOS Swift formatting + linting via Apple's `swift format` (config:
|
||||
# web/ios/.swift-format). The wrapper no-ops when the Swift toolchain
|
||||
# is absent, so these run on macOS dev machines but skip the ubuntu-latest
|
||||
|
||||
@@ -5,6 +5,10 @@ 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`.
|
||||
|
||||
## [v0.4.0] — 2026-07-03
|
||||
|
||||
Highlights and full notes: <https://github.com/omnigent-ai/omnigent/releases/tag/v0.4.0>
|
||||
|
||||
## [v0.3.0] — 2026-06-26
|
||||
|
||||
Highlights and full notes: <https://github.com/omnigent-ai/omnigent/releases/tag/v0.3.0>
|
||||
|
||||
@@ -179,7 +179,7 @@ mirrors work out of the box; override with `OMNIGENT_INDEX_URL` if needed.
|
||||
also launches a local web UI at `http://localhost:6767` that shows the same
|
||||
session in the browser, or on a phone on your network (step 4). The
|
||||
[desktop app](https://omnigent.ai/docs/interact/desktop) wraps that same UI
|
||||
in a native window and adds OS notifications and a dock badge —
|
||||
in a native window and adds OS notifications (with a configurable sound) and a dock badge —
|
||||
[download it for macOS](https://omnigent.ai/download/mac).
|
||||
|
||||
> [!NOTE]
|
||||
@@ -451,6 +451,10 @@ Polly at [`examples/polly/`](https://github.com/omnigent-ai/omnigent/tree/main/e
|
||||
|
||||
Contributions are welcome. See [CONTRIBUTING.md](https://github.com/omnigent-ai/omnigent/blob/main/CONTRIBUTING.md) for how to set up your environment, run the checks, and open a pull request.
|
||||
|
||||
Adding or changing support for a harness (Claude, Codex, Cursor, OpenCode,
|
||||
Hermes, Pi, ...)? Run the [harness test bench](https://github.com/omnigent-ai/omnigent/tree/main/tests/harness_bench)
|
||||
to check its capability matrix against observed behavior.
|
||||
|
||||
|
||||
### Contributors
|
||||
|
||||
|
||||
@@ -143,6 +143,14 @@ POSTGRES_PASSWORD=change-me-please
|
||||
# ── Optional OIDC tuning ─────────────────────────────────
|
||||
# OMNIGENT_OIDC_SESSION_TTL_HOURS=8
|
||||
# OMNIGENT_OIDC_LOGOUT_REDIRECT_URI=https://omnigent.example.com/
|
||||
#
|
||||
# Skip the email_verified claim check on id_tokens. Some IdPs (e.g.
|
||||
# Okta without custom API Access Management) omit the claim for
|
||||
# directory-provisioned users, which otherwise fails login with
|
||||
# "Could not determine user email". Only enable when the issuer is a
|
||||
# trusted enterprise directory — it makes any signed email claim the
|
||||
# user's identity. Off by default.
|
||||
# OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION=1
|
||||
|
||||
# ── Server config file (admins, allowed domains, …) ──────
|
||||
# Non-secret settings live in a YAML config file — the same one
|
||||
|
||||
@@ -211,7 +211,7 @@ RUN apt-get update \
|
||||
# user-namespace remapping the sandbox user maps to an unprivileged, unused
|
||||
# host id. Unused by the root-based providers.
|
||||
RUN groupadd -g 1000660000 sandbox \
|
||||
&& useradd -m -u 1000660000 -g sandbox sandbox
|
||||
&& useradd -m -d /sandbox -u 1000660000 -g sandbox sandbox
|
||||
|
||||
# Git credential helper for private repositories over HTTPS: answers
|
||||
# `git credential get` from GIT_TOKEN / GIT_USERNAME in the
|
||||
@@ -328,11 +328,14 @@ RUN set -eu; \
|
||||
fi; \
|
||||
echo "agy ${AGY_VERSION} pinned (sha256 verified)"
|
||||
|
||||
# Preserve /build/ — the venv's editable install .pth files reference
|
||||
# /build/omnigent and /build/sdks/* by absolute path. Copying these to
|
||||
# /app/ would break the import paths silently.
|
||||
# Copy the venv and source tree. The editable install's .pth files reference
|
||||
# /build/omnigent and /build/sdks/* -- both denied by the k8s Landlock LSM
|
||||
# policy. Re-install without -e so the package bytes land in the venv's
|
||||
# site-packages and imports no longer require /build at runtime.
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
COPY --from=builder /build /build
|
||||
RUN pip install --no-cache-dir /build /build/sdks/python-client /build/sdks/ui \
|
||||
&& ! grep -R --include='*.pth' --include='*.egg-link' -nE '/build(/|$)' /opt/venv/lib/python*/site-packages
|
||||
|
||||
# Sandbox launchers exec commands through `bash -lc`, and Debian's
|
||||
# /etc/profile unconditionally resets PATH for login shells — the ENV
|
||||
|
||||
@@ -40,3 +40,10 @@ allowed_domains:
|
||||
# Extra Python modules scanned for POLICY_REGISTRY lists at startup.
|
||||
# policy_modules:
|
||||
# - myorg.policies.safety
|
||||
|
||||
# Copy-at-spawn limits. When a parent agent forwards files to a subagent,
|
||||
# the server copies them through the destination session. These bound a
|
||||
# single copy request so it can't spike shared-server memory; omit to use
|
||||
# the built-in defaults (20 files / 256 MiB total).
|
||||
# copy_max_files: 20
|
||||
# copy_max_total_bytes: 268435456
|
||||
|
||||
@@ -97,6 +97,10 @@ services:
|
||||
OMNIGENT_OIDC_SESSION_TTL_HOURS: "${OMNIGENT_OIDC_SESSION_TTL_HOURS:-8}"
|
||||
OMNIGENT_OIDC_ALLOWED_DOMAINS: "${OMNIGENT_OIDC_ALLOWED_DOMAINS:-}"
|
||||
OMNIGENT_OIDC_LOGOUT_REDIRECT_URI: "${OMNIGENT_OIDC_LOGOUT_REDIRECT_URI:-}"
|
||||
# Skip the email_verified id_token check — for IdPs (e.g. Okta
|
||||
# without API Access Management) that omit the claim for
|
||||
# directory-provisioned users. Off unless set; see .env.example.
|
||||
OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION: "${OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION:-}"
|
||||
# Opt-in OIDC invites (admin pre-authorizes one off-domain email).
|
||||
# Off unless set. The admin list (/data/admins) and the optional
|
||||
# allowed-domains file (/data/allowed_domains) need no env var —
|
||||
|
||||
@@ -249,13 +249,12 @@ The `overlays/sandbox-runners/` overlay turns on the **`kubernetes`** managed
|
||||
sandbox provider: a `host_type: managed` session spawns one runner Pod that runs
|
||||
`omnigent host` as its entrypoint and dials back over the launch-token tunnel. It
|
||||
adds a dedicated runner namespace, a least-privilege server SA (scoped Pod +
|
||||
Secret rights, **no `pods/exec`**), and the `sandbox:` server config. The server
|
||||
image must be built with the `kubernetes` extra
|
||||
(`--build-arg OMNIGENT_EXTRAS=kubernetes`). See
|
||||
`overlays/sandbox-runners/README.md` for the full guide.
|
||||
Secret rights, **no `pods/exec`**), and the `sandbox:` server config. The
|
||||
overlay swaps in the official `omnigent-server-kubernetes` image variant, which
|
||||
adds the `kubernetes` client extra the provider imports (the base server image
|
||||
omits it). See `overlays/sandbox-runners/README.md` for the full guide.
|
||||
|
||||
```bash
|
||||
# set the server image in overlays/sandbox-runners/kustomization.yaml first
|
||||
kubectl apply -k deploy/kubernetes/overlays/sandbox-runners
|
||||
# then create the omnigent-creds harness Secret (see the overlay README)
|
||||
```
|
||||
|
||||
@@ -42,10 +42,11 @@ the generated runner Pod is already restricted-compliant (non-root uid 1000, dro
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **A server image built with the `kubernetes` extra.** The base image omits
|
||||
it, so `_ensure_sdk()` would fail every launch. Build with
|
||||
`--build-arg OMNIGENT_EXTRAS=kubernetes` (see `deploy/docker`) and set the
|
||||
image in `kustomization.yaml` (`images:` → `newName`/`newTag`).
|
||||
1. **A server image built with the `kubernetes` extra.** The overlay's
|
||||
`images:` block already points at the official `omnigent-server-kubernetes`
|
||||
variant, which includes it — nothing to build. If you self-build instead,
|
||||
keep `kubernetes` in `OMNIGENT_EXTRAS` (see `deploy/docker`) or
|
||||
`_ensure_sdk()` fails every launch, and point `images:` at your build.
|
||||
2. **Harness credentials.** The runners read their LLM / git credentials from a
|
||||
Secret named by `secret_name` (default `omnigent-creds`); you create it out of
|
||||
band after applying the overlay — see step 2 of **Apply**. It is deliberately
|
||||
@@ -130,9 +131,9 @@ writing nothing to disk — use HTTPS repository URLs. Details by provider match
|
||||
| `namespace` | Runner-Pod namespace (defaults to `omnigent-sandboxes`). |
|
||||
| `secret_name` | Harness-creds Secret projected into every Pod via `envFrom`. |
|
||||
| `service_account` | ServiceAccount the runner Pods run as (powerless). |
|
||||
| `image` | Optional runner image override (defaults to the official amd64 host image). |
|
||||
| `image` | Optional runner image override (defaults to the official multi-arch amd64/arm64 host image). |
|
||||
| `env` | Optional list of SERVER env-var names to inject as literal Pod env (prefer `secret_name` for credentials). |
|
||||
| `node_selector` | Optional extra node labels, merged with the mandatory `kubernetes.io/arch: amd64`. |
|
||||
| `node_selector` | Optional extra node labels, merged with a default `kubernetes.io/arch: amd64` — set that key to `arm64` to schedule runners on arm64 nodes. (arm64 note: the CEL policy module is unavailable there — `cel-expr-python` ships no aarch64 wheel — and degrades gracefully.) |
|
||||
| `resources` | Optional `requests` / `limits` (`cpu` / `memory`) override. |
|
||||
| `in_cluster` | Optional cluster-config source: `true` (in-cluster SA only), `false` (kubeconfig only), omit (try in-cluster, then kubeconfig). |
|
||||
| `kubeconfig` | Optional kubeconfig path for the out-of-cluster fallback (env: `OMNIGENT_KUBERNETES_KUBECONFIG`). |
|
||||
|
||||
@@ -17,13 +17,14 @@ resources:
|
||||
# omnigent-creds) is NOT checked in — create it out of band like the base
|
||||
# OIDC secret (see README.md "Apply"). Prefer sealed-secrets/external-secrets.
|
||||
|
||||
# The base server image lacks the `kubernetes` extra, so a managed launch would
|
||||
# fail to import the client. Build the server WITH it
|
||||
# (`--build-arg OMNIGENT_EXTRAS=kubernetes`, see deploy/docker) and set it here.
|
||||
# Use the server image variant that includes the kubernetes client extra
|
||||
# (built by CI with OMNIGENT_EXTRAS=kubernetes). The base image omits it, so a
|
||||
# managed launch there fails to import the client. Self-builds must keep
|
||||
# `kubernetes` in OMNIGENT_EXTRAS (see deploy/docker); point newName at such a
|
||||
# build here if you use one.
|
||||
images:
|
||||
- name: ghcr.io/omnigent-ai/omnigent-server
|
||||
newName: ghcr.io/REPLACE_ME/omnigent-server
|
||||
newTag: kubernetes
|
||||
newName: ghcr.io/omnigent-ai/omnigent-server-kubernetes
|
||||
|
||||
patches:
|
||||
- path: deployment-patch.yaml
|
||||
|
||||
@@ -28,9 +28,9 @@ data:
|
||||
# ServiceAccount the runner Pods run as (deliberately powerless).
|
||||
service_account: omnigent-runner
|
||||
# ── all optional below ──
|
||||
# image: ghcr.io/your-org/omnigent-host:latest # default: official amd64 host image
|
||||
# image: ghcr.io/your-org/omnigent-host:latest # default: official multi-arch (amd64/arm64) host image
|
||||
# env: [PROXY_URL] # SERVER env vars injected as literal Pod env (prefer secret_name for creds)
|
||||
# node_selector: # extra node labels, merged with the mandatory kubernetes.io/arch: amd64
|
||||
# node_selector: # extra node labels; default kubernetes.io/arch: amd64, override to arm64 to run there
|
||||
# disktype: ssd
|
||||
# resources: # runner Pod sizing (defaults: 0.5-2 cpu / 1-4Gi)
|
||||
# requests: {cpu: "500m", memory: "1Gi"}
|
||||
|
||||
@@ -73,10 +73,22 @@ Replaces `manifest._P0_ALL_SUPPORTED`:
|
||||
|
||||
| Bench probe | Backing capability | Declared verdict rule |
|
||||
|---|---|---|
|
||||
| `interrupt` | `interrupt: bool` | `True` → `SUPPORTED`, else `PARTIAL`/`UNSUPPORTED` |
|
||||
| `streaming` | `streaming: bool` | `True` → `SUPPORTED` (deltas), else `PARTIAL` (complete-only) |
|
||||
| `interrupt` | `interrupt: bool` | `True` → `SUPPORTED`, `False` → `UNSUPPORTED` |
|
||||
| `streaming` | `streaming: bool` | `True` → `SUPPORTED` (deltas), `False` → `UNSUPPORTED` (see note) |
|
||||
| `model_override` | `SDK_MODEL_OVERRIDE_HARNESSES` (already in the registry via `model_env_keys()`) or `native` metadata | already derivable from #1756; no new field |
|
||||
|
||||
> **Correction (implemented, supersedes the original `False → PARTIAL` idea).**
|
||||
> `streaming` is **binary**: `False → UNSUPPORTED`, not `PARTIAL`. `PARTIAL`
|
||||
> is a *probe observation only* — the streaming probe returns it for the
|
||||
> ambiguous coalesced-single-delta case against a `SUPPORTED` declaration — and
|
||||
> is **never a declared value**. Declaring a non-streaming harness `PARTIAL`
|
||||
> drifts against reality, because the probe reports zero deltas as
|
||||
> `UNSUPPORTED`. This was found live: kiro/cursor/qwen-native observe 0 deltas
|
||||
> and are declared `False → UNSUPPORTED` (no drift). The rule now: **declare
|
||||
> `streaming=False` only from a live observation of 0 deltas** — a static
|
||||
> "the forwarder posts no delta" grep is not sufficient (pi-native has no
|
||||
> delta-posting forwarder yet streams live).
|
||||
|
||||
### C. Probe-only — no capability backing; leave hand-declared
|
||||
These are behaviors with no single trait to key off. Keep them in the manifest
|
||||
as-is (or a small explicit table):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Performance benchmarks (runnable via ``uv run``, not shipped)."""
|
||||
@@ -0,0 +1,243 @@
|
||||
# Omnigent performance benchmark
|
||||
|
||||
Baseline, repeatable latency/throughput numbers for key Omnigent user
|
||||
journeys, so we can track them over time and catch regressions. Modeled on
|
||||
MLflow's `dev/benchmarks/gateway/` workflow.
|
||||
|
||||
The harness boots a real `omnigent server`, drives the selected journeys under
|
||||
load, prints latency/throughput tables, and writes a versioned JSON report.
|
||||
Two families: **HTTP/API journeys** (server + DB, no runner/LLM — fast and
|
||||
low-noise) and **full-turn journeys** (a real agent turn through the runner +
|
||||
a zero-latency mock LLM). See *Journeys* below.
|
||||
|
||||
By default the server boots a fresh, empty SQLite DB, which gives best-case
|
||||
numbers that don't move with load. For meaningful results, point it at a
|
||||
**pre-seeded corpus** (`seed.py`) and, ideally, at **Postgres** — production
|
||||
runs on Databricks Lakebase (Postgres), whose per-query round-trip + pooling
|
||||
cost SQLite doesn't have. See *Seeding* and *Backends* below.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
# All journeys, sequential latency (100 iterations × 3 runs each).
|
||||
uv run --no-sync dev/benchmarks/omnigent/run.py
|
||||
|
||||
# A subset, writing a report for CI artifact upload.
|
||||
uv run --no-sync dev/benchmarks/omnigent/run.py \
|
||||
--journeys list_sessions,load_conversation_history \
|
||||
--iterations 200 --runs 3 --output bench.json
|
||||
|
||||
# Throughput mode: >1 concurrency drives concurrency-safe journeys as load.
|
||||
uv run --no-sync dev/benchmarks/omnigent/run.py \
|
||||
--requests 500 --concurrency 25 --runs 3
|
||||
|
||||
# CI gating: exit 1 if a threshold is breached.
|
||||
uv run --no-sync dev/benchmarks/omnigent/run.py --max-p50-ms 25 --max-p99-ms 100
|
||||
```
|
||||
|
||||
`--no-sync` runs against the already-installed venv. (A bare `uv run` may try to
|
||||
rebuild the project, which fails in a git worktree without a Node web-UI build;
|
||||
`OMNIGENT_SKIP_WEB_UI=true uv sync` prepares the venv once, then use
|
||||
`--no-sync`.)
|
||||
|
||||
Key flags (`--help` for all): `--journeys A,B`, `--database-uri URI` (seeded
|
||||
corpus / Postgres; default: throwaway empty SQLite), `--iterations N` (per
|
||||
latency run), `--requests N` / `--concurrency N` (throughput), `--runs N`,
|
||||
`--warmup N`, `--output FILE`, `--min-rps` / `--max-p50-ms` / `--max-p99-ms`
|
||||
(CI thresholds).
|
||||
|
||||
## Journeys
|
||||
|
||||
### HTTP/API (server + DB, runner-free)
|
||||
|
||||
| Journey | Operation timed | Stressed by |
|
||||
| --- | --- | --- |
|
||||
| `list_sessions` | `GET /v1/sessions` — session-list read | session count |
|
||||
| `create_session` | `POST /v1/sessions` then `DELETE` — session create | write path |
|
||||
| `get_session` | `GET /v1/sessions/{id}` — single-session snapshot | (O(1)) |
|
||||
| `load_conversation_history` | `GET /v1/sessions/{id}/items` — history read | items/session |
|
||||
| `search_sessions` | `GET /v1/sessions?search_query=` — unindexed `LIKE` | total item count |
|
||||
| `fork_session` | `POST /v1/sessions/{id}/fork` — fork (deep-copy items); forks deleted in teardown, untimed | items/session |
|
||||
| `add_comment` | `POST /v1/sessions/{id}/comments` — create a review comment | write path |
|
||||
|
||||
Read journeys target a **pre-seeded** session when the DB has a corpus; against
|
||||
an empty DB they self-seed a small fallback session over HTTP (the
|
||||
`external_conversation_item` event — appends items without starting a task), so
|
||||
they still work with no runner or LLM.
|
||||
|
||||
### Full-turn (runner + mock LLM)
|
||||
|
||||
These drive a real agent turn end-to-end — `POST …/events` → server → **runner**
|
||||
→ in-process executor → mock LLM → stream back → `idle`. Selecting any of them
|
||||
boots `BenchEnvironment(with_runner=True)` automatically.
|
||||
|
||||
Each turn costs ~1 s+ (vs. the millisecond HTTP journeys), so these journeys
|
||||
cap their latency iterations (`Journey.max_iterations`, currently 5) — a large
|
||||
`--iterations` tuned for the HTTP journeys is clamped down for them so the run
|
||||
stays within the CI time budget, with `--runs` providing the repeats. The cap
|
||||
only lowers the count, never raises it. A cold start never deletes its session,
|
||||
so sessions accumulate across a run; keeping the count small also keeps that
|
||||
drift negligible (~2 ms/turn).
|
||||
|
||||
| Journey | Operation timed |
|
||||
| --- | --- |
|
||||
| `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 |
|
||||
|
||||
`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
|
||||
cap (50) than the full-turn journeys.
|
||||
|
||||
**Only measure what we control.** Full-turn journeys always use the
|
||||
**`openai-agents`** SDK harness, which runs **in-process** (a call into the
|
||||
`agents` library + an HTTP call to the mock LLM) — no vendor binary, no external
|
||||
process. Native harnesses (e.g. `claude-native`) launch the real vendor CLI
|
||||
into a tmux pane, whose startup we don't control, so they're deliberately
|
||||
excluded. The mock LLM is zero-latency, so every number is omnigent
|
||||
dispatch/streaming/cancel overhead, not model latency.
|
||||
|
||||
Add a journey by registering a `Journey` in `journeys.py` (set `needs_runner`
|
||||
for full-turn journeys).
|
||||
|
||||
## Seeding a realistic corpus
|
||||
|
||||
`seed.py` writes a sizeable, deterministic corpus directly through the store
|
||||
API (no HTTP, no runner) into the same DB the server then boots against:
|
||||
|
||||
```bash
|
||||
# Seed 5000 sessions × 50 items into a SQLite file, then benchmark against it.
|
||||
uv run --no-sync dev/benchmarks/omnigent/seed.py \
|
||||
--database-uri sqlite:////abs/path/bench.db --sessions 5000 --items-per-session 50
|
||||
uv run --no-sync dev/benchmarks/omnigent/run.py \
|
||||
--database-uri sqlite:////abs/path/bench.db --output bench.json
|
||||
```
|
||||
|
||||
Seeding is **idempotent**: a matching corpus (same sessions/items/schema) is
|
||||
detected and reused, so re-running is a fast no-op — pass `--reseed` to force,
|
||||
or a differing config to be warned. SQLite absolute paths need four slashes
|
||||
(`sqlite:////abs/...`). The reuse marker records the DB's Alembic head read at
|
||||
seed time, so a corpus from an older schema is automatically reseeded — no
|
||||
manual revision bookkeeping. `test_seed_creates_listable_corpus` (which seeds
|
||||
through the store, running migrations to the current head) is the safety net
|
||||
that a schema change hasn't broken seeding.
|
||||
|
||||
## Backends
|
||||
|
||||
`--database-uri` selects the DB; the report's `backend` field (`sqlite` /
|
||||
`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
|
||||
`+psycopg` form; the server CLI does not normalize a bare `postgresql://`).
|
||||
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`.
|
||||
|
||||
## Output → Databricks → dashboard
|
||||
|
||||
The harness writes JSON only. Storage and charting live in Databricks:
|
||||
|
||||
```
|
||||
run.py --output bench.json → GitHub Actions artifact → Databricks notebook (ETL) → Delta table → AI/BI dashboard
|
||||
(this repo) (CI, follow-up) (workspace, yours)
|
||||
```
|
||||
|
||||
The repo's contract is the **JSON schema** below. A workspace notebook (owned
|
||||
outside this repo, modeled on MLflow's gateway ETL) pulls the CI artifacts via
|
||||
the GitHub API, flattens each run's `summary` + `runs` + metadata, and
|
||||
`saveAsTable`s into a Delta table the dashboard reads. `sample_output.json` is a
|
||||
committed, faithful example so the notebook can be written against a real
|
||||
document without running the harness.
|
||||
|
||||
### JSON schema (`schema.py`, `SCHEMA_VERSION`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schema_version": 1,
|
||||
"generated_at": "<ISO-8601 UTC>",
|
||||
"git_sha": "<HEAD sha>",
|
||||
"git_branch": "<branch>",
|
||||
"host": {"platform": "...", "python": "...", "cpu_count": 12},
|
||||
"harness": "http-only",
|
||||
"config": {"iterations": 100, "requests": 500, "concurrency": 1,
|
||||
"runs": 3, "warmup": 10, "with_runner": false,
|
||||
"backend": "sqlite"},
|
||||
"journeys": {
|
||||
"<journey name>": {
|
||||
"kind": "latency" | "throughput",
|
||||
"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": …,
|
||||
"p99_ms": …, "max_ms": …, "rps": …}
|
||||
],
|
||||
"summary": {"avg_mean_ms": …, "avg_p50_ms": …, "avg_p95_ms": …,
|
||||
"avg_p99_ms": …, "avg_rps": …} // averaged across runs
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The per-journey `summary` + `runs` shape mirrors MLflow's gateway benchmark, so
|
||||
the same ETL flatten works — keyed by `journey` and `backend`. Bump
|
||||
`SCHEMA_VERSION` on any breaking shape change so the notebook can branch on it.
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `run.py` | CLI orchestrator + entrypoint |
|
||||
| `seed.py` | deterministic corpus seeder (store API) |
|
||||
| `journeys.py` | `Journey` dataclass, latency/throughput runners, registry |
|
||||
| `environment.py` | server (± runner + mock LLM) lifecycle; `--database-uri` |
|
||||
| `measure.py` | `RunResult`, percentile, aggregation, thresholds, tables |
|
||||
| `schema.py` | `SCHEMA_VERSION`, `build_report`, git/host metadata |
|
||||
| `sample_output.json` | committed example of the JSON contract |
|
||||
|
||||
The smoke test is `tests/benchmarks/test_benchmark_smoke.py` (boots the server
|
||||
with tiny counts + a seeded-corpus unit test; runs on the normal CI lane, no
|
||||
creds).
|
||||
|
||||
## CI
|
||||
|
||||
`.github/workflows/benchmark.yml` runs nightly (and on dispatch) as a backend
|
||||
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.
|
||||
|
||||
Schema changes need no manual step: the seed always targets the current
|
||||
migrated schema (migrations run when the store is constructed), the reuse
|
||||
marker records the head read at seed time (so old corpora auto-reseed), and
|
||||
`test_seed_creates_listable_corpus` fails if a migration genuinely breaks
|
||||
seeding.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- **Subagent spawn.** A planned full-turn journey (`needs_runner=True`): the
|
||||
parent agent emits a `sys_session_send` tool call, the runner dispatches a
|
||||
child session, and the parent auto-wakes with the collected result. It's
|
||||
fully mockable with the zero-latency mock LLM (no real model) — script the
|
||||
parent's queue to emit the tool call and the child's queue to return a short
|
||||
reply, then poll for the child's marker. It needs the parent bundle to declare
|
||||
a sub-agent under `tools:` (extend `_agent_bundle`); the pattern is in
|
||||
`tests/e2e/test_coder_subagent.py`.
|
||||
- **Excluded journeys** (agent-behaviour-dependent, deliberately not measured):
|
||||
multi-turn and tool-calling turns (dominated by the agent's own choices) and
|
||||
large-history turns (the O(N) `history_to_input_items` conversion is real app
|
||||
work but only fires on a cold runner cache, so isolating it entangles with
|
||||
cold-start cost).
|
||||
- **CI matrix.** Runner journeys are backend-agnostic (they exercise runner
|
||||
dispatch, not big DB reads), so the nightly workflow can run them on the
|
||||
SQLite leg only rather than both — wire a runner `--journeys` set into
|
||||
`benchmark.yml` when desired.
|
||||
- **Simulated provider latency.** The mock LLM returns at ~zero latency, which
|
||||
is what isolates omnigent overhead. A fixed per-response delay knob would let
|
||||
turns model end-user wall-clock instead; it's a small change behind the
|
||||
`configure_mock` / `set_mock_fallback` seam if that's ever wanted.
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Omnigent user-journey performance benchmark.
|
||||
|
||||
Stands up a real server + runner against a zero-latency mock LLM, drives
|
||||
key user journeys under load, and emits a versioned JSON report of latency
|
||||
percentiles and throughput. See ``README.md`` for the workflow and how the
|
||||
workspace ETL notebook consumes the JSON.
|
||||
"""
|
||||
@@ -0,0 +1,694 @@
|
||||
"""Benchmark environment lifecycle.
|
||||
|
||||
:class:`BenchEnvironment` is an async context manager that stands up a real
|
||||
Omnigent ``server`` with no Databricks credentials. Two modes:
|
||||
|
||||
- ``with_runner=False`` (default): server + SQLite DB only. Enough for the
|
||||
HTTP/API journeys, which never drive an agent turn.
|
||||
- ``with_runner=True``: additionally spawns a zero-latency mock LLM and a
|
||||
sibling ``runner``, routes the server-side prompt-policy classifier at the
|
||||
mock (via ``--config``), and sets an ALLOW fallback — everything the
|
||||
full-turn journeys need.
|
||||
|
||||
A full env is a strict superset of the HTTP-only env, so both modes share one
|
||||
class; the runner mode is gated behind the flag rather than forked into a
|
||||
separate type. It mirrors the proven ``live_server`` e2e recipe
|
||||
(``tests/e2e/conftest.py``) and reuses the credential-free spawn core: the
|
||||
compat helpers (so subprocesses import this worktree) and
|
||||
``token_bound_runner_id``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import IO
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
from omnigent.runner.identity import OMNIGENT_INTERNAL_WS_ORIGIN, token_bound_runner_id
|
||||
from tests._helpers.compat import (
|
||||
apply_runner_env,
|
||||
apply_server_env,
|
||||
compat_runner_cwd,
|
||||
compat_server_cwd,
|
||||
runner_executable,
|
||||
server_executable,
|
||||
)
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
_MOCK_SERVER = _REPO_ROOT / "tests" / "server" / "integration" / "mock_llm_server.py"
|
||||
|
||||
_HEALTH_TIMEOUT_S = 90.0
|
||||
_MOCK_TIMEOUT_S = 15.0
|
||||
_POLL_INTERVAL_S = 0.2
|
||||
_TURN_TIMEOUT_S = 180.0
|
||||
|
||||
# Terminal SSE events — if one arrives before any delta, the turn produced no
|
||||
# streamed text (a failure for the TTFT journey).
|
||||
_STREAM_TERMINAL_EVENTS = frozenset(
|
||||
{"response.completed", "response.failed", "response.cancelled"}
|
||||
)
|
||||
# The server persists an interrupted turn as a synthetic user message whose
|
||||
# text contains this marker (see tests/e2e/test_cancel_history.py).
|
||||
_CANCELLATION_MARKER = "interrupted"
|
||||
|
||||
# Default full-turn agent (with_runner=True). The mock ignores the model for
|
||||
# routing (its "default" queue serves any request), but the key is baked into
|
||||
# the spec so the harness has a concrete model to send.
|
||||
_DEFAULT_MODEL = "mock-bench-brain"
|
||||
_DEFAULT_HARNESS = "openai-agents"
|
||||
|
||||
# Server-side prompt-policy classifier queue key. In runner mode we set an
|
||||
# ALLOW fallback here so a classifier call (if the agent trips one) never
|
||||
# blocks or returns non-verdict text.
|
||||
_POLICY_LLM_KEY = "_policy_llm_"
|
||||
_POLICY_ALLOW = '{"action": "allow", "reason": ""}'
|
||||
|
||||
|
||||
def _find_free_port() -> int:
|
||||
"""Bind an ephemeral port and return it (races are tolerated by retries)."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
class BenchEnvironment:
|
||||
"""Async context manager owning the benchmark's server (± runner + mock).
|
||||
|
||||
:param with_runner: When ``False`` (default), boot the server only — the
|
||||
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 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
|
||||
``postgresql+psycopg://…`` instance) to benchmark against a realistic
|
||||
corpus. Postgres must be the fully-qualified ``+psycopg`` form — the
|
||||
server CLI does not normalize it.
|
||||
:param harness: Harness for full-turn agents when ``with_runner`` (default
|
||||
``openai-agents``, a base dependency needing no vendor CLI binary).
|
||||
:param model: Model string baked into registered agent specs.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
with_runner: bool = False,
|
||||
database_uri: str | None = None,
|
||||
harness: str = _DEFAULT_HARNESS,
|
||||
model: str = _DEFAULT_MODEL,
|
||||
) -> None:
|
||||
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.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._log_handles: list[IO[bytes]] = []
|
||||
self._agent_cache: dict[str, str] = {}
|
||||
|
||||
# ── lifecycle ────────────────────────────────────────────
|
||||
|
||||
async def __aenter__(self) -> BenchEnvironment:
|
||||
await asyncio.to_thread(self._start)
|
||||
self.client = httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
timeout=300.0,
|
||||
headers={"Origin": OMNIGENT_INTERNAL_WS_ORIGIN},
|
||||
)
|
||||
if self.with_runner:
|
||||
# ALLOW fallback so a server-side classifier call resolves against
|
||||
# the mock (never api.openai.com) and returns a valid verdict.
|
||||
await self._mock_post(
|
||||
"/mock/set_fallback", {"key": _POLICY_LLM_KEY, "text": _POLICY_ALLOW}
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
if self.client is not None:
|
||||
await self.client.aclose()
|
||||
await asyncio.to_thread(self._stop)
|
||||
|
||||
def _start(self) -> None:
|
||||
"""Spawn the server (± mock + runner) and block until ready."""
|
||||
self._tmp.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
artifact_dir = self._tmp / "artifacts"
|
||||
artifact_dir.mkdir(exist_ok=True)
|
||||
|
||||
if self.with_runner:
|
||||
mock_port = _find_free_port()
|
||||
self.mock_url = f"http://127.0.0.1:{mock_port}"
|
||||
self._mock_proc = self._spawn_mock(mock_port)
|
||||
self._wait_mock_ready()
|
||||
|
||||
port = _find_free_port()
|
||||
self.base_url = f"http://localhost:{port}"
|
||||
binding_token = uuid.uuid4().hex
|
||||
|
||||
base_env = {**os.environ}
|
||||
if self.with_runner:
|
||||
self.runner_id = token_bound_runner_id(binding_token)
|
||||
base_env["OPENAI_API_KEY"] = "mock-key"
|
||||
# The OpenAI SDK appends /responses, so include /v1 in the base.
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
def _stop(self) -> None:
|
||||
"""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:
|
||||
proc.wait(timeout=8)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
for handle in self._log_handles:
|
||||
handle.close()
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(self._tmp, ignore_errors=True)
|
||||
|
||||
# ── spawns ───────────────────────────────────────────────
|
||||
|
||||
def _log(self, name: str) -> IO[bytes]:
|
||||
handle = (self._tmp / name).open("wb")
|
||||
self._log_handles.append(handle)
|
||||
return handle
|
||||
|
||||
def _spawn_mock(self, port: int) -> subprocess.Popen[bytes]:
|
||||
return subprocess.Popen(
|
||||
[sys.executable, str(_MOCK_SERVER), str(port)],
|
||||
env={**os.environ, "PYTHONPATH": str(_REPO_ROOT)},
|
||||
stdout=self._log("mock.log"),
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
|
||||
def _spawn_server(
|
||||
self,
|
||||
port: int,
|
||||
base_env: dict[str, str],
|
||||
binding_token: str,
|
||||
artifact_dir: Path,
|
||||
) -> subprocess.Popen[bytes]:
|
||||
# Pre-seeded URI when given (realistic corpus), else a throwaway SQLite
|
||||
# file in the temp dir (the empty-DB path). SQLite absolute paths need
|
||||
# four slashes; the temp path is absolute.
|
||||
db_uri = self.database_uri or f"sqlite:///{self._tmp / 'bench.db'}"
|
||||
args = [
|
||||
server_executable(),
|
||||
"-m",
|
||||
"omnigent.cli",
|
||||
"server",
|
||||
"--port",
|
||||
str(port),
|
||||
"--database-uri",
|
||||
db_uri,
|
||||
"--artifact-location",
|
||||
str(artifact_dir),
|
||||
]
|
||||
env = {**base_env}
|
||||
if self.with_runner:
|
||||
# Route the server-side policy-classifier LLM at the mock, mirroring
|
||||
# live_server. Without this the classifier's client defaults to
|
||||
# api.openai.com and errors. Server-only mode needs no llm config —
|
||||
# the classifier only builds under OMNIGENT_SMART_ROUTING=1.
|
||||
server_cfg = self._tmp / "server.yaml"
|
||||
server_cfg.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"llm": {
|
||||
"model": _POLICY_LLM_KEY,
|
||||
"connection": {
|
||||
"base_url": f"{self.mock_url}/v1",
|
||||
"api_key": "mock-key",
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
args.extend(["--config", str(server_cfg)])
|
||||
env["OMNIGENT_RUNNER_TUNNEL_TOKEN"] = binding_token
|
||||
return subprocess.Popen(
|
||||
args,
|
||||
env=env,
|
||||
cwd=compat_server_cwd(),
|
||||
stdout=self._log("server.log"),
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
|
||||
def _spawn_runner(
|
||||
self, base_env: dict[str, str], binding_token: str
|
||||
) -> subprocess.Popen[bytes]:
|
||||
# Point the runner's filesystem workspace at the temp dir so file
|
||||
# writes (e.g. read_runner_file's setup) land there and are cleaned up
|
||||
# on teardown, rather than in the launch cwd (its default).
|
||||
workspace = self._tmp / "workspace"
|
||||
workspace.mkdir(exist_ok=True)
|
||||
runner_env = apply_runner_env(
|
||||
{
|
||||
**base_env,
|
||||
"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,
|
||||
"OMNIGENT_RUNNER_WORKSPACE": str(workspace),
|
||||
}
|
||||
)
|
||||
return subprocess.Popen(
|
||||
[runner_executable(), "-m", "omnigent.runner._entry"],
|
||||
env=runner_env,
|
||||
cwd=compat_runner_cwd(),
|
||||
stdout=self._log("runner.log"),
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
|
||||
# ── readiness ────────────────────────────────────────────
|
||||
|
||||
def _wait_mock_ready(self) -> None:
|
||||
deadline = time.monotonic() + _MOCK_TIMEOUT_S
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
if httpx.get(f"{self.mock_url}/stats", timeout=1).status_code == 200:
|
||||
return
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(f"mock LLM not ready within {_MOCK_TIMEOUT_S}s; logs in {self._tmp}")
|
||||
|
||||
def _wait_ready(self) -> None:
|
||||
"""Wait for ``/health`` (and, in runner mode, the runner online)."""
|
||||
deadline = time.monotonic() + _HEALTH_TIMEOUT_S
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
health = httpx.get(f"{self.base_url}/health", timeout=2)
|
||||
if health.status_code == 200 and self._runner_ready():
|
||||
return
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
time.sleep(_POLL_INTERVAL_S)
|
||||
raise RuntimeError(f"server not ready within {_HEALTH_TIMEOUT_S}s; logs in {self._tmp}")
|
||||
|
||||
def _runner_ready(self) -> bool:
|
||||
"""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
|
||||
|
||||
# ── mock control (runner mode only) ──────────────────────
|
||||
|
||||
async def _mock_post(self, path: str, body: dict[str, object]) -> None:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.post(f"{self.mock_url}{path}", json=body)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def configure_mock(
|
||||
self,
|
||||
responses: list[dict[str, object]],
|
||||
*,
|
||||
key: str = "default",
|
||||
match: str | None = None,
|
||||
) -> None:
|
||||
"""Load a keyed response queue on the mock (see e2e ``configure_mock_llm``)."""
|
||||
payload: dict[str, object] = {"key": key, "responses": responses}
|
||||
if match is not None:
|
||||
payload["match"] = match
|
||||
await self._mock_post("/mock/configure", payload)
|
||||
|
||||
async def set_mock_fallback(
|
||||
self, text: str, *, key: str = "default", stream: bool = False
|
||||
) -> None:
|
||||
"""Set a reset-surviving fallback response for a mock queue *key*.
|
||||
|
||||
:param stream: When ``True`` the fallback emits per-word
|
||||
``output_text.delta`` events before completing — needed for the
|
||||
time-to-first-token journey to observe streamed deltas.
|
||||
"""
|
||||
await self._mock_post("/mock/set_fallback", {"key": key, "text": text, "stream": stream})
|
||||
|
||||
# ── agent + session primitives ───────────────────────────
|
||||
|
||||
def _agent_bundle(self, name: str) -> bytes:
|
||||
"""Build a ``spec_version: 1`` agent bundle.
|
||||
|
||||
In runner mode the executor is wired at the mock LLM (auth +
|
||||
connection). Server-only, no LLM is ever called, so the bundle just
|
||||
needs to be a valid spec the server can register and bind sessions to.
|
||||
"""
|
||||
executor: dict[str, object] = {
|
||||
"type": "omnigent",
|
||||
"model": self.model,
|
||||
"config": {"harness": self.harness},
|
||||
}
|
||||
config: dict[str, object] = {
|
||||
"spec_version": 1,
|
||||
"name": name,
|
||||
"prompt": "You are a helpful assistant used for performance benchmarking.",
|
||||
"executor": executor,
|
||||
}
|
||||
if self.with_runner:
|
||||
executor["auth"] = {
|
||||
"type": "api_key",
|
||||
"api_key": "mock-key",
|
||||
"base_url": f"{self.mock_url}/v1",
|
||||
}
|
||||
executor["connection"] = {"base_url": f"{self.mock_url}/v1", "api_key": "mock-key"}
|
||||
# A filesystem env so the runner can serve the resource endpoints
|
||||
# (read_runner_file). Without os_env the runner has no primary
|
||||
# environment to materialize and the filesystem proxy 404s.
|
||||
# sandbox.type=none avoids needing a bwrap binary on the host.
|
||||
config["os_env"] = {
|
||||
"type": "caller_process",
|
||||
"cwd": ".",
|
||||
"sandbox": {"type": "none"},
|
||||
}
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
||||
payload = yaml.safe_dump(config).encode()
|
||||
info = tarfile.TarInfo("config.yaml")
|
||||
info.size = len(payload)
|
||||
tar.addfile(info, io.BytesIO(payload))
|
||||
return buf.getvalue()
|
||||
|
||||
async def ensure_agent(self, name: str = "bench-agent") -> str:
|
||||
"""Register the benchmark agent once, returning its name (idempotent)."""
|
||||
assert self.client is not None
|
||||
if name in self._agent_cache:
|
||||
return name
|
||||
resp = await self.client.post(
|
||||
"/v1/sessions",
|
||||
data={"metadata": "{}"},
|
||||
files={"bundle": ("agent.tar.gz", self._agent_bundle(name), "application/gzip")},
|
||||
)
|
||||
if resp.status_code not in (200, 201, 409):
|
||||
raise RuntimeError(f"agent register failed: {resp.status_code} {resp.text[:400]}")
|
||||
self._agent_cache[name] = name
|
||||
return name
|
||||
|
||||
async def agent_id(self, agent_name: str) -> str:
|
||||
"""Resolve a registered agent's id by name."""
|
||||
assert self.client is not None
|
||||
listing = await self.client.get(
|
||||
"/v1/sessions", params={"agent_name": agent_name, "limit": 1}
|
||||
)
|
||||
listing.raise_for_status()
|
||||
return str(listing.json()["data"][0]["agent_id"])
|
||||
|
||||
async def create_session(self, agent_id: str) -> str:
|
||||
"""Create an (unbound) session for *agent_id*, returning its id."""
|
||||
assert self.client is not None
|
||||
created = await self.client.post("/v1/sessions", json={"agent_id": agent_id})
|
||||
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.
|
||||
|
||||
Uses the ``external_conversation_item`` event, which the server
|
||||
appends "without starting or steering a task" — the runner-free path
|
||||
for giving ``load_conversation_history`` something to read back.
|
||||
|
||||
Items are user messages: assistant messages require an ``agent`` field
|
||||
the server only has after a real turn, and the read path this seeds is
|
||||
role-agnostic — item count and size, not role, drive its cost.
|
||||
"""
|
||||
assert self.client is not None
|
||||
for i in range(count):
|
||||
body = {
|
||||
"type": "external_conversation_item",
|
||||
"data": {
|
||||
"item_type": "message",
|
||||
"item_data": {
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": f"benchmark seed item {i}"}],
|
||||
},
|
||||
},
|
||||
}
|
||||
resp = await self.client.post(f"/v1/sessions/{session_id}/events", json=body)
|
||||
resp.raise_for_status()
|
||||
|
||||
# ── 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 runner."""
|
||||
assert self.client is not None
|
||||
if not self.with_runner:
|
||||
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": self.runner_id}
|
||||
)
|
||||
bound.raise_for_status()
|
||||
return session_id
|
||||
|
||||
async def write_runner_file(self, session_id: str, relative_path: str, content: str) -> None:
|
||||
"""Write a file into the runner's default environment over HTTP.
|
||||
|
||||
The server proxies the ``PUT`` to the bound runner, which writes to its
|
||||
sandboxed filesystem — so this needs a runner. Used to plant a file the
|
||||
read journey can then fetch back.
|
||||
|
||||
:raises RuntimeError: If not in runner mode.
|
||||
"""
|
||||
assert self.client is not None
|
||||
if not self.with_runner:
|
||||
raise RuntimeError("write_runner_file requires with_runner=True")
|
||||
resp = await self.client.put(
|
||||
f"/v1/sessions/{session_id}/resources/environments/default/filesystem/{relative_path}",
|
||||
json={"content": content, "encoding": "utf-8"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def read_runner_file(self, session_id: str, relative_path: str) -> None:
|
||||
"""Read a file from the runner's default environment over HTTP.
|
||||
|
||||
Times the server → runner filesystem proxy (a localhost round-trip); no
|
||||
LLM is involved. Requires a runner — the server returns 502 without one.
|
||||
|
||||
:raises RuntimeError: If not in runner mode.
|
||||
"""
|
||||
assert self.client is not None
|
||||
if not self.with_runner:
|
||||
raise RuntimeError("read_runner_file requires with_runner=True")
|
||||
resp = await self.client.get(
|
||||
f"/v1/sessions/{session_id}/resources/environments/default/filesystem/{relative_path}",
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def drive_turn(
|
||||
self, session_id: str, text: str, *, timeout: float = _TURN_TIMEOUT_S
|
||||
) -> None:
|
||||
"""Post a user message and poll the session to a terminal state.
|
||||
|
||||
:raises RuntimeError: If not in runner mode, the turn fails, or it does
|
||||
not settle within *timeout* seconds.
|
||||
"""
|
||||
assert self.client is not None
|
||||
if not self.with_runner:
|
||||
raise RuntimeError("drive_turn requires with_runner=True")
|
||||
body = {
|
||||
"type": "message",
|
||||
"data": {"role": "user", "content": [{"type": "input_text", "text": text}]},
|
||||
}
|
||||
posted = await self.client.post(f"/v1/sessions/{session_id}/events", json=body)
|
||||
posted.raise_for_status()
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
seen_running = False
|
||||
while time.monotonic() < deadline:
|
||||
snap = await self.client.get(f"/v1/sessions/{session_id}")
|
||||
snap.raise_for_status()
|
||||
status = snap.json().get("status")
|
||||
if status in ("running", "waiting"):
|
||||
seen_running = True
|
||||
elif status == "failed":
|
||||
raise RuntimeError(f"turn failed: {snap.json().get('last_task_error')}")
|
||||
elif status == "idle" and seen_running:
|
||||
return
|
||||
await asyncio.sleep(_POLL_INTERVAL_S)
|
||||
raise RuntimeError(f"turn did not settle within {timeout}s (session {session_id})")
|
||||
|
||||
async def _wait_idle(self, session_id: str, *, timeout: float = _TURN_TIMEOUT_S) -> None:
|
||||
"""Poll until the session is ``idle`` (a prior turn has settled)."""
|
||||
assert self.client is not None
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
snap = await self.client.get(f"/v1/sessions/{session_id}")
|
||||
snap.raise_for_status()
|
||||
if snap.json().get("status") == "idle":
|
||||
return
|
||||
await asyncio.sleep(_POLL_INTERVAL_S)
|
||||
raise RuntimeError(f"session did not reach idle within {timeout}s ({session_id})")
|
||||
|
||||
async def time_to_first_delta(
|
||||
self, session_id: str, text: str, *, timeout: float = _TURN_TIMEOUT_S
|
||||
) -> None:
|
||||
"""Post a turn and return once the first output-text delta streams back.
|
||||
|
||||
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.
|
||||
|
||||
: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("time_to_first_delta requires with_runner=True")
|
||||
|
||||
connected = asyncio.Event()
|
||||
first_delta = asyncio.Event()
|
||||
outcome: dict[str, str] = {}
|
||||
|
||||
async def _read_stream() -> None:
|
||||
try:
|
||||
async with self.client.stream( # type: ignore[union-attr]
|
||||
"GET", f"/v1/sessions/{session_id}/stream", timeout=timeout
|
||||
) as resp:
|
||||
# Any first line means the SSE connection is live (the server
|
||||
# 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:"):
|
||||
continue
|
||||
etype = line[len("event:") :].strip()
|
||||
if etype == "response.output_text.delta":
|
||||
first_delta.set()
|
||||
return
|
||||
if etype in _STREAM_TERMINAL_EVENTS:
|
||||
outcome["terminal"] = etype
|
||||
first_delta.set()
|
||||
return
|
||||
except httpx.HTTPError as exc:
|
||||
outcome["error"] = repr(exc)
|
||||
connected.set()
|
||||
first_delta.set()
|
||||
|
||||
# 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 delta, not subscription setup.
|
||||
await asyncio.wait_for(connected.wait(), timeout=timeout)
|
||||
posted = await self.client.post(
|
||||
f"/v1/sessions/{session_id}/events",
|
||||
json={
|
||||
"type": "message",
|
||||
"data": {"role": "user", "content": [{"type": "input_text", "text": text}]},
|
||||
},
|
||||
)
|
||||
posted.raise_for_status()
|
||||
try:
|
||||
await asyncio.wait_for(first_delta.wait(), timeout=timeout)
|
||||
except TimeoutError as exc:
|
||||
raise RuntimeError(
|
||||
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 delta (session {session_id})"
|
||||
)
|
||||
finally:
|
||||
reader.cancel()
|
||||
|
||||
async def drive_and_interrupt(
|
||||
self, session_id: str, *, timeout: float = _TURN_TIMEOUT_S
|
||||
) -> None:
|
||||
"""Drive a gated turn, interrupt it mid-flight, return when cancelled.
|
||||
|
||||
The caller configures a ``block=True`` mock response first (see
|
||||
:meth:`configure_mock`), so the turn parks in ``running`` on the
|
||||
executor's LLM call. We post an ``interrupt`` once running, wait for the
|
||||
server's cancellation marker, then release the gate so the runner
|
||||
unwinds cleanly. Times the server → runner → executor cancel path.
|
||||
|
||||
:raises RuntimeError: If not in runner mode, or the interrupt is not
|
||||
honored within *timeout*.
|
||||
"""
|
||||
assert self.client is not None
|
||||
if not self.with_runner:
|
||||
raise RuntimeError("drive_and_interrupt requires with_runner=True")
|
||||
body = {
|
||||
"type": "message",
|
||||
"data": {"role": "user", "content": [{"type": "input_text", "text": "Interrupt me."}]},
|
||||
}
|
||||
posted = await self.client.post(f"/v1/sessions/{session_id}/events", json=body)
|
||||
posted.raise_for_status()
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
interrupted = False
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
snap = (await self.client.get(f"/v1/sessions/{session_id}")).json()
|
||||
status = snap.get("status")
|
||||
items = snap.get("items", [])
|
||||
if status in ("running", "waiting") and not interrupted:
|
||||
await self.client.post(
|
||||
f"/v1/sessions/{session_id}/events", json={"type": "interrupt"}
|
||||
)
|
||||
interrupted = True
|
||||
if _has_cancellation_marker(items):
|
||||
return
|
||||
if status == "idle" and interrupted:
|
||||
if _has_cancellation_marker(items):
|
||||
return
|
||||
raise RuntimeError("turn settled without a cancellation marker")
|
||||
await asyncio.sleep(_POLL_INTERVAL_S)
|
||||
raise RuntimeError(f"interrupt not honored within {timeout}s (session {session_id})")
|
||||
finally:
|
||||
# Always release the gate so the blocked runner turn unwinds and
|
||||
# teardown doesn't hang, even if the interrupt path errored above.
|
||||
with contextlib.suppress(httpx.HTTPError):
|
||||
await self._mock_post("/gate/release", {})
|
||||
|
||||
|
||||
def _has_cancellation_marker(items: list[dict[str, object]]) -> bool:
|
||||
"""Whether items include the synthetic 'interrupted' user message."""
|
||||
for raw in items:
|
||||
data = raw.get("data", raw)
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
if raw.get("type") == "message" and data.get("role") == "user":
|
||||
content = data.get("content") or []
|
||||
if isinstance(content, list) and any(
|
||||
isinstance(b, dict) and _CANCELLATION_MARKER in str(b.get("text", ""))
|
||||
for b in content
|
||||
):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,559 @@
|
||||
"""User-journey definitions and the runners that time them.
|
||||
|
||||
A :class:`Journey` names a user-facing operation, an optional per-journey
|
||||
``setup`` that returns a context object, and a ``measure`` coroutine — the
|
||||
timed unit. :func:`run_latency` times ``measure`` sequentially; journeys marked
|
||||
``concurrency_safe`` can also be driven by :func:`run_throughput` with many
|
||||
operations in flight.
|
||||
|
||||
v1 journeys are pure HTTP/API (server + DB, no runner, no LLM):
|
||||
|
||||
- ``list_sessions`` — the session-list read behind the sidebar/home.
|
||||
- ``create_session`` — session creation cost (POST then DELETE).
|
||||
- ``get_session`` — single-session snapshot load.
|
||||
- ``load_conversation_history`` — history read, seeded runner-free via
|
||||
``external_conversation_item`` (see :meth:`BenchEnvironment.seed_items`).
|
||||
- ``fork_session`` — fork a session (deep-copy its items), then DELETE.
|
||||
- ``add_comment`` — create a review comment on a file (DB write).
|
||||
|
||||
``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.
|
||||
|
||||
The framework (``Journey`` + the two runners) is harness-agnostic and reused
|
||||
verbatim by phase-2 full-turn journeys.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, cast
|
||||
|
||||
import httpx
|
||||
|
||||
from .environment import BenchEnvironment
|
||||
from .measure import RunResult
|
||||
|
||||
# Per-journey context returned by ``setup`` and threaded to ``measure``. Its
|
||||
# concrete type varies by journey (an agent id, a session id, or nothing), so
|
||||
# it is opaque at the framework level; each measure op casts it as needed.
|
||||
JourneyContext = object
|
||||
|
||||
JourneyKind = Literal["latency", "throughput"]
|
||||
|
||||
# Items requested per history-read page. Also the count self-seeded into a
|
||||
# fallback session when the DB has no corpus (empty-DB smoke path).
|
||||
_HISTORY_PAGE_LIMIT = 20
|
||||
_HISTORY_SEED_ITEMS = _HISTORY_PAGE_LIMIT
|
||||
|
||||
|
||||
@dataclass
|
||||
class Journey:
|
||||
"""One benchmarkable user journey.
|
||||
|
||||
:param name: Stable identifier used on the CLI and as the report key.
|
||||
:param kind: ``"latency"`` (time each operation) or ``"throughput"``
|
||||
(fixed request count under concurrency). A latency journey that is
|
||||
``concurrency_safe`` can additionally be run as throughput.
|
||||
:param measure: Coroutine performing exactly one timed operation, given
|
||||
the environment and the setup context.
|
||||
:param setup: Optional coroutine run once before timing; its return value
|
||||
is passed to ``measure`` (and ``teardown``) as ``ctx``.
|
||||
:param teardown: Optional coroutine run once after timing, given ``ctx``.
|
||||
:param concurrency_safe: Whether many ``measure`` calls may run at once
|
||||
against a shared setup (true for read-only / independent-write HTTP
|
||||
journeys).
|
||||
: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 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
|
||||
few samples per run and lean on ``--runs`` for repeats. ``None`` (HTTP
|
||||
journeys) means no cap.
|
||||
:param description: Human-readable one-liner for ``--list``.
|
||||
"""
|
||||
|
||||
name: str
|
||||
kind: JourneyKind
|
||||
measure: Callable[[BenchEnvironment, JourneyContext], Awaitable[None]]
|
||||
setup: Callable[[BenchEnvironment], Awaitable[JourneyContext]] | None = None
|
||||
teardown: Callable[[BenchEnvironment, JourneyContext], Awaitable[None]] | None = None
|
||||
concurrency_safe: bool = False
|
||||
needs_runner: bool = False
|
||||
max_iterations: int | None = None
|
||||
description: str = ""
|
||||
|
||||
async def run_setup(self, env: BenchEnvironment) -> JourneyContext:
|
||||
return await self.setup(env) if self.setup is not None else None
|
||||
|
||||
async def run_teardown(self, env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
if self.teardown is not None:
|
||||
await self.teardown(env, ctx)
|
||||
|
||||
|
||||
# ── timed operation (shared by both runners) ─────────────────
|
||||
|
||||
|
||||
async def _timed(
|
||||
journey: Journey, env: BenchEnvironment, ctx: JourneyContext, result: RunResult
|
||||
) -> None:
|
||||
"""Run one ``measure`` op, recording its latency or a failure reason."""
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
await journey.measure(env, ctx)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
result.record_failure(f"HTTP {exc.response.status_code}")
|
||||
except Exception as exc: # noqa: BLE001 — any failure is a recorded data point
|
||||
result.record_failure(exc.__class__.__name__)
|
||||
else:
|
||||
result.latencies_ms.append((time.perf_counter() - start) * 1000)
|
||||
|
||||
|
||||
# ── runners ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def run_latency(
|
||||
journey: Journey, env: BenchEnvironment, *, iterations: int, warmup: int
|
||||
) -> RunResult:
|
||||
"""Time *iterations* sequential operations after discarding *warmup*.
|
||||
|
||||
Warmup operations run through the same path but are excluded from the
|
||||
result, so first-call import/JIT/connection costs don't skew the numbers.
|
||||
"""
|
||||
ctx = await journey.run_setup(env)
|
||||
try:
|
||||
for _ in range(warmup):
|
||||
with contextlib.suppress(Exception): # warmup errors are non-fatal
|
||||
await journey.measure(env, ctx)
|
||||
result = RunResult()
|
||||
wall_start = time.perf_counter()
|
||||
for _ in range(iterations):
|
||||
await _timed(journey, env, ctx, result)
|
||||
result.wall_time = time.perf_counter() - wall_start
|
||||
return result
|
||||
finally:
|
||||
await journey.run_teardown(env, ctx)
|
||||
|
||||
|
||||
async def run_throughput(
|
||||
journey: Journey,
|
||||
env: BenchEnvironment,
|
||||
*,
|
||||
requests: int,
|
||||
concurrency: int,
|
||||
warmup: int,
|
||||
) -> RunResult:
|
||||
"""Fire *requests* operations with at most *concurrency* in flight.
|
||||
|
||||
Wall time spans from the first dispatch to the last completion, so
|
||||
``throughput`` reflects sustained req/s under load (MLflow's ``_run_once``
|
||||
shape, with an :class:`asyncio.Semaphore` gate).
|
||||
"""
|
||||
ctx = await journey.run_setup(env)
|
||||
try:
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
|
||||
async def _one(count_it: bool, result: RunResult) -> None:
|
||||
async with sem:
|
||||
if count_it:
|
||||
await _timed(journey, env, ctx, result)
|
||||
else:
|
||||
with contextlib.suppress(Exception): # warmup errors are non-fatal
|
||||
await journey.measure(env, ctx)
|
||||
|
||||
if warmup:
|
||||
throwaway = RunResult()
|
||||
await asyncio.gather(*[_one(False, throwaway) for _ in range(warmup)])
|
||||
|
||||
result = RunResult()
|
||||
wall_start = time.perf_counter()
|
||||
await asyncio.gather(*[_one(True, result) for _ in range(requests)])
|
||||
result.wall_time = time.perf_counter() - wall_start
|
||||
return result
|
||||
finally:
|
||||
await journey.run_teardown(env, ctx)
|
||||
|
||||
|
||||
# ── journey implementations ──────────────────────────────────
|
||||
#
|
||||
# Setups return the context each measure op needs. Ops must be independent so
|
||||
# concurrency-safe journeys don't interfere across in-flight calls.
|
||||
|
||||
|
||||
# A token present in the seeded corpus (titles + item text, see seed.py
|
||||
# _FRAGMENTS) so search_sessions exercises the LIKE path with real matches.
|
||||
_SEARCH_TOKEN = "runner"
|
||||
|
||||
|
||||
async def _setup_agent_id(env: BenchEnvironment) -> str:
|
||||
"""Register the benchmark agent and return its id."""
|
||||
name = await env.ensure_agent()
|
||||
return await env.agent_id(name)
|
||||
|
||||
|
||||
async def _setup_target_session(env: BenchEnvironment) -> str:
|
||||
"""Return a session id to read: an existing corpus session if any, else make one.
|
||||
|
||||
Real runs target a pre-seeded corpus (``seed.py``), so we read a
|
||||
representative existing session. When the DB is empty (e.g. the smoke test
|
||||
against a throwaway DB), fall back to creating one with a little history so
|
||||
the journey still exercises the read path.
|
||||
"""
|
||||
assert env.client is not None
|
||||
listing = await env.client.get("/v1/sessions", params={"limit": 1})
|
||||
listing.raise_for_status()
|
||||
data = listing.json().get("data", [])
|
||||
if data:
|
||||
return str(data[0]["id"])
|
||||
# Empty DB: self-seed one session over HTTP (runner-free).
|
||||
name = await env.ensure_agent()
|
||||
agent_id = await env.agent_id(name)
|
||||
session_id = await env.create_session(agent_id)
|
||||
await env.seed_items(session_id, _HISTORY_SEED_ITEMS)
|
||||
return session_id
|
||||
|
||||
|
||||
async def _measure_list_sessions(env: BenchEnvironment, _ctx: JourneyContext) -> None:
|
||||
assert env.client is not None
|
||||
resp = await env.client.get("/v1/sessions", params={"limit": 20})
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
async def _measure_search_sessions(env: BenchEnvironment, _ctx: JourneyContext) -> None:
|
||||
assert env.client is not None
|
||||
resp = await env.client.get(
|
||||
"/v1/sessions", params={"limit": 20, "search_query": _SEARCH_TOKEN}
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
async def _measure_create_session(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
assert env.client is not None
|
||||
agent_id = cast(str, ctx) # _setup_agent_id
|
||||
created = await env.client.post("/v1/sessions", json={"agent_id": agent_id})
|
||||
created.raise_for_status()
|
||||
# Delete inline so a long run doesn't accumulate unbounded sessions; the
|
||||
# POST is the operation of interest and dominates the timed span.
|
||||
session_id = created.json()["id"]
|
||||
deleted = await env.client.delete(f"/v1/sessions/{session_id}")
|
||||
deleted.raise_for_status()
|
||||
|
||||
|
||||
async def _measure_get_session(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
assert env.client is not None
|
||||
session_id = cast(str, ctx) # _setup_target_session
|
||||
resp = await env.client.get(f"/v1/sessions/{session_id}")
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
async def _measure_load_history(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
assert env.client is not None
|
||||
session_id = cast(str, ctx) # _setup_target_session
|
||||
resp = await env.client.get(
|
||||
f"/v1/sessions/{session_id}/items",
|
||||
params={"order": "asc", "limit": _HISTORY_PAGE_LIMIT},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ForkContext:
|
||||
"""Fork-journey context: the session to fork + the forks to clean up.
|
||||
|
||||
``measure`` records each fork's id here instead of deleting it inline, so
|
||||
the DELETE stays out of the timed span; ``teardown`` removes them after.
|
||||
"""
|
||||
|
||||
source_id: str
|
||||
fork_ids: list[str]
|
||||
|
||||
|
||||
async def _setup_fork_session(env: BenchEnvironment) -> _ForkContext:
|
||||
"""Resolve a session to fork; start an empty fork-id collector."""
|
||||
source_id = await _setup_target_session(env)
|
||||
return _ForkContext(source_id=source_id, fork_ids=[])
|
||||
|
||||
|
||||
async def _measure_fork_session(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
assert env.client is not None
|
||||
fork_ctx = cast(_ForkContext, ctx) # _setup_fork_session
|
||||
forked = await env.client.post(f"/v1/sessions/{fork_ctx.source_id}/fork", json={})
|
||||
forked.raise_for_status()
|
||||
# Record the fork for teardown; deleting it here would fold the DELETE into
|
||||
# the timed span. The fork POST (a deep-copy of the source's items) is the
|
||||
# operation of interest.
|
||||
fork_ctx.fork_ids.append(forked.json()["id"])
|
||||
|
||||
|
||||
async def _teardown_fork_session(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
"""Delete every fork created during the run (best effort, untimed)."""
|
||||
assert env.client is not None
|
||||
fork_ctx = cast(_ForkContext, ctx)
|
||||
for fork_id in fork_ctx.fork_ids:
|
||||
with contextlib.suppress(httpx.HTTPError):
|
||||
await env.client.delete(f"/v1/sessions/{fork_id}")
|
||||
|
||||
|
||||
# Anchor snapshot for the comment journey; the offsets below span it.
|
||||
_COMMENT_ANCHOR = "benchmark"
|
||||
|
||||
|
||||
async def _measure_add_comment(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
assert env.client is not None
|
||||
session_id = cast(str, ctx) # _setup_target_session
|
||||
# Each POST creates an independent comment row. Unlike sessions, an
|
||||
# accumulating comment skews no measured read path, so there's no cleanup.
|
||||
# The file need not exist — the handler stores the path + offsets + body.
|
||||
resp = await env.client.post(
|
||||
f"/v1/sessions/{session_id}/comments",
|
||||
json={
|
||||
"path": "bench_target.py",
|
||||
"body": "benchmark review comment",
|
||||
"start_index": 0,
|
||||
"end_index": len(_COMMENT_ANCHOR),
|
||||
"anchor_content": _COMMENT_ANCHOR,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
# ── runner (full-turn) journeys ──────────────────────────────
|
||||
#
|
||||
# These drive a real agent turn through the runner + mock LLM (with_runner=True,
|
||||
# openai-agents). The mock is zero-latency, so every number is omnigent dispatch
|
||||
# / streaming / cancel overhead, not model latency. Short deterministic replies.
|
||||
|
||||
# A multi-word reply so the streaming path emits several output_text deltas.
|
||||
_TURN_REPLY = "Hello there, this is a mock benchmark reply."
|
||||
_TURN_PROMPT = "Say hello."
|
||||
|
||||
# Iteration cap for full-turn journeys. At ~1s+ per turn, matching the HTTP
|
||||
# journeys' iteration count would overrun the CI time budget, so we take a few
|
||||
# samples per run and lean on --runs for repeats. Sessions accumulate across a
|
||||
# run (a cold start never deletes its session), so a small count also keeps that
|
||||
# drift negligible.
|
||||
_RUNNER_MAX_ITERATIONS = 5
|
||||
|
||||
# Iteration cap for the runner filesystem read. It's a proxied localhost read,
|
||||
# not a full turn, so it's far cheaper than the drive-a-turn journeys — a higher
|
||||
# cap gives a usable p50/p99 while staying well within the CI time budget.
|
||||
_RUNNER_FS_MAX_ITERATIONS = 50
|
||||
|
||||
# File planted by the read-runner-file setup and fetched by its measure op.
|
||||
# ~1 KB — a modest, representative source file, not a stress case.
|
||||
_RUNNER_FILE_PATH = "bench_read_target.txt"
|
||||
_RUNNER_FILE_CONTENT = "benchmark file content line\n" * 40
|
||||
|
||||
|
||||
async def _setup_turn_agent(env: BenchEnvironment, *, stream: bool = False) -> str:
|
||||
"""Register the agent + a reset-surviving reply; return the agent id.
|
||||
|
||||
The fallback survives per-call queue exhaustion, so every turn in the run
|
||||
gets the same reply regardless of how many turns consume the queue. When
|
||||
*stream* is set the reply emits per-word deltas (for the TTFT journey).
|
||||
"""
|
||||
name = await env.ensure_agent()
|
||||
await env.set_mock_fallback(_TURN_REPLY, stream=stream)
|
||||
return await env.agent_id(name)
|
||||
|
||||
|
||||
async def _setup_warm_session(env: BenchEnvironment) -> str:
|
||||
"""Create+bind a session and drive one warm-up turn; return the session id.
|
||||
|
||||
The warm-up pays the cold-start cost (runner spawn + executor construction)
|
||||
so the measured op times only steady-state per-turn overhead.
|
||||
"""
|
||||
agent_id = await _setup_turn_agent(env)
|
||||
session_id = await env.create_bound_session(agent_id)
|
||||
await env.drive_turn(session_id, _TURN_PROMPT)
|
||||
return session_id
|
||||
|
||||
|
||||
async def _setup_streaming_session(env: BenchEnvironment) -> str:
|
||||
"""Warm session whose mock reply streams deltas — for the TTFT journey."""
|
||||
agent_id = await _setup_turn_agent(env, stream=True)
|
||||
session_id = await env.create_bound_session(agent_id)
|
||||
await env.drive_turn(session_id, _TURN_PROMPT)
|
||||
return session_id
|
||||
|
||||
|
||||
async def _setup_interrupt_session(env: BenchEnvironment) -> str:
|
||||
"""Create+bind a session for the interrupt journey; return the session id.
|
||||
|
||||
Configures a ``block=True`` mock response so each turn parks in ``running``
|
||||
until the gate is released — giving the interrupt something to cancel
|
||||
mid-flight, deterministically.
|
||||
"""
|
||||
name = await env.ensure_agent()
|
||||
agent_id = await env.agent_id(name)
|
||||
session_id = await env.create_bound_session(agent_id)
|
||||
await env.configure_mock([{"text": _TURN_REPLY, "block": True}])
|
||||
return session_id
|
||||
|
||||
|
||||
async def _measure_session_cold_start(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
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:
|
||||
session_id = cast(str, ctx) # _setup_warm_session
|
||||
await env.drive_turn(session_id, _TURN_PROMPT)
|
||||
|
||||
|
||||
async def _measure_time_to_first_token(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
session_id = cast(str, ctx) # _setup_warm_session
|
||||
await env.time_to_first_delta(session_id, _TURN_PROMPT)
|
||||
|
||||
|
||||
async def _measure_interrupt(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
session_id = cast(str, ctx) # _setup_interrupt_session
|
||||
await env.drive_and_interrupt(session_id)
|
||||
|
||||
|
||||
async def _setup_runner_file_session(env: BenchEnvironment) -> str:
|
||||
"""Bind a session to the runner and plant a file to read; return its id.
|
||||
|
||||
No turn is driven and no mock reply is configured — the measured op is a
|
||||
filesystem read proxied to the runner, which never calls the LLM.
|
||||
"""
|
||||
name = await env.ensure_agent()
|
||||
agent_id = await env.agent_id(name)
|
||||
session_id = await env.create_bound_session(agent_id)
|
||||
await env.write_runner_file(session_id, _RUNNER_FILE_PATH, _RUNNER_FILE_CONTENT)
|
||||
return session_id
|
||||
|
||||
|
||||
async def _measure_read_runner_file(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
session_id = cast(str, ctx) # _setup_runner_file_session
|
||||
await env.read_runner_file(session_id, _RUNNER_FILE_PATH)
|
||||
|
||||
|
||||
# ── registry ─────────────────────────────────────────────────
|
||||
|
||||
ALL_JOURNEYS: dict[str, Journey] = {
|
||||
j.name: j
|
||||
for j in (
|
||||
Journey(
|
||||
name="list_sessions",
|
||||
kind="latency",
|
||||
measure=_measure_list_sessions,
|
||||
concurrency_safe=True,
|
||||
description="GET /v1/sessions — session list read.",
|
||||
),
|
||||
Journey(
|
||||
name="create_session",
|
||||
kind="latency",
|
||||
measure=_measure_create_session,
|
||||
setup=_setup_agent_id,
|
||||
concurrency_safe=True,
|
||||
description="POST /v1/sessions then DELETE — session create.",
|
||||
),
|
||||
Journey(
|
||||
name="get_session",
|
||||
kind="latency",
|
||||
measure=_measure_get_session,
|
||||
setup=_setup_target_session,
|
||||
concurrency_safe=True,
|
||||
description="GET /v1/sessions/{id} — single-session snapshot.",
|
||||
),
|
||||
Journey(
|
||||
name="load_conversation_history",
|
||||
kind="latency",
|
||||
measure=_measure_load_history,
|
||||
setup=_setup_target_session,
|
||||
concurrency_safe=True,
|
||||
description="GET /v1/sessions/{id}/items — conversation history read.",
|
||||
),
|
||||
Journey(
|
||||
name="search_sessions",
|
||||
kind="latency",
|
||||
measure=_measure_search_sessions,
|
||||
concurrency_safe=True,
|
||||
description="GET /v1/sessions?search_query= — unindexed LIKE over titles + items.",
|
||||
),
|
||||
Journey(
|
||||
name="fork_session",
|
||||
kind="latency",
|
||||
measure=_measure_fork_session,
|
||||
setup=_setup_fork_session,
|
||||
teardown=_teardown_fork_session,
|
||||
concurrency_safe=True,
|
||||
description="POST /v1/sessions/{id}/fork — session fork (deep-copy); DELETE untimed.",
|
||||
),
|
||||
Journey(
|
||||
name="add_comment",
|
||||
kind="latency",
|
||||
measure=_measure_add_comment,
|
||||
setup=_setup_target_session,
|
||||
concurrency_safe=True,
|
||||
description="POST /v1/sessions/{id}/comments — create a review comment.",
|
||||
),
|
||||
# Runner (full-turn) journeys — with_runner=True, openai-agents, mock LLM.
|
||||
Journey(
|
||||
name="session_cold_start",
|
||||
kind="latency",
|
||||
measure=_measure_session_cold_start,
|
||||
setup=_setup_turn_agent,
|
||||
needs_runner=True,
|
||||
max_iterations=_RUNNER_MAX_ITERATIONS,
|
||||
description="Create+bind a fresh session and drive its first turn to idle.",
|
||||
),
|
||||
Journey(
|
||||
name="warm_turn",
|
||||
kind="latency",
|
||||
measure=_measure_warm_turn,
|
||||
setup=_setup_warm_session,
|
||||
needs_runner=True,
|
||||
max_iterations=_RUNNER_MAX_ITERATIONS,
|
||||
description="Drive a turn on an already-warm session (steady-state overhead).",
|
||||
),
|
||||
Journey(
|
||||
name="time_to_first_token",
|
||||
kind="latency",
|
||||
measure=_measure_time_to_first_token,
|
||||
setup=_setup_streaming_session,
|
||||
needs_runner=True,
|
||||
max_iterations=_RUNNER_MAX_ITERATIONS,
|
||||
description="Post a turn; time to the first streamed output_text delta.",
|
||||
),
|
||||
Journey(
|
||||
name="interrupt",
|
||||
kind="latency",
|
||||
measure=_measure_interrupt,
|
||||
setup=_setup_interrupt_session,
|
||||
needs_runner=True,
|
||||
max_iterations=_RUNNER_MAX_ITERATIONS,
|
||||
description="Interrupt a running (gated) turn; time to cancellation.",
|
||||
),
|
||||
Journey(
|
||||
name="read_runner_file",
|
||||
kind="latency",
|
||||
measure=_measure_read_runner_file,
|
||||
setup=_setup_runner_file_session,
|
||||
needs_runner=True,
|
||||
max_iterations=_RUNNER_FS_MAX_ITERATIONS,
|
||||
description="GET .../environments/default/filesystem/{path} — runner file read proxy.",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def resolve_journeys(names: list[str] | None) -> list[Journey]:
|
||||
"""Resolve requested journey *names* (or all when ``None``/empty).
|
||||
|
||||
:raises KeyError: If a requested name isn't registered.
|
||||
"""
|
||||
if not names:
|
||||
return list(ALL_JOURNEYS.values())
|
||||
resolved = []
|
||||
for name in names:
|
||||
if name not in ALL_JOURNEYS:
|
||||
raise KeyError(f"unknown journey {name!r}; known: {', '.join(ALL_JOURNEYS)}")
|
||||
resolved.append(ALL_JOURNEYS[name])
|
||||
return resolved
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Latency/throughput measurement primitives.
|
||||
|
||||
Pure and I/O-free: a :class:`RunResult` accumulates per-operation latencies
|
||||
and failures for one timed run, :func:`aggregate` folds several runs into the
|
||||
``runs`` + ``summary`` shape the workspace ETL flattens, and
|
||||
:func:`check_thresholds` gates a run in CI. Adapted from MLflow's
|
||||
``dev/benchmarks/gateway/benchmark.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import statistics
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunResult:
|
||||
"""Latencies and failures collected during one timed run.
|
||||
|
||||
:param latencies_ms: Per-operation wall-clock latency in milliseconds,
|
||||
one entry per successful operation.
|
||||
:param failures: Failure reason (e.g. ``"HTTP 500"`` / an exception
|
||||
class name) mapped to how many times it occurred.
|
||||
:param wall_time: Total elapsed seconds for the run, used for throughput.
|
||||
"""
|
||||
|
||||
latencies_ms: list[float] = field(default_factory=list)
|
||||
failures: dict[str, int] = field(default_factory=dict)
|
||||
wall_time: float = 0.0
|
||||
|
||||
@property
|
||||
def n_success(self) -> int:
|
||||
"""Number of operations that completed without error."""
|
||||
return len(self.latencies_ms)
|
||||
|
||||
@property
|
||||
def n_failures(self) -> int:
|
||||
"""Total failed operations across all reasons."""
|
||||
return sum(self.failures.values())
|
||||
|
||||
@property
|
||||
def throughput(self) -> float:
|
||||
"""Successful operations per second over the run's wall time."""
|
||||
return self.n_success / self.wall_time if self.wall_time > 0 else 0.0
|
||||
|
||||
def record_failure(self, reason: str) -> None:
|
||||
"""Increment the count for one failure *reason*."""
|
||||
self.failures[reason] = self.failures.get(reason, 0) + 1
|
||||
|
||||
def percentile(self, p: float) -> float:
|
||||
"""Return the *p*-th percentile latency in ms (ceil-index method).
|
||||
|
||||
:param p: Percentile in ``[0, 100]``, e.g. ``99`` for p99.
|
||||
:returns: The latency at that percentile, or ``0.0`` when no
|
||||
successful operation was recorded.
|
||||
"""
|
||||
if not self.latencies_ms:
|
||||
return 0.0
|
||||
ordered = sorted(self.latencies_ms)
|
||||
idx = max(0, math.ceil(p / 100 * len(ordered)) - 1)
|
||||
return ordered[idx]
|
||||
|
||||
def mean_ms(self) -> float:
|
||||
"""Mean latency in ms, or ``0.0`` when no operation succeeded."""
|
||||
return statistics.mean(self.latencies_ms) if self.latencies_ms else 0.0
|
||||
|
||||
def max_ms(self) -> float:
|
||||
"""Maximum latency in ms, or ``0.0`` when no operation succeeded."""
|
||||
return max(self.latencies_ms) if self.latencies_ms else 0.0
|
||||
|
||||
|
||||
def _run_to_dict(result: RunResult) -> dict[str, object]:
|
||||
"""Flatten one :class:`RunResult` into a JSON-serializable per-run row."""
|
||||
return {
|
||||
"n_success": result.n_success,
|
||||
"n_failures": result.n_failures,
|
||||
"failures": dict(result.failures),
|
||||
"wall_time_s": result.wall_time,
|
||||
"mean_ms": result.mean_ms(),
|
||||
"p50_ms": result.percentile(50),
|
||||
"p95_ms": result.percentile(95),
|
||||
"p99_ms": result.percentile(99),
|
||||
"max_ms": result.max_ms(),
|
||||
"rps": result.throughput,
|
||||
}
|
||||
|
||||
|
||||
def aggregate(results: list[RunResult]) -> dict[str, object]:
|
||||
"""Fold per-run results into ``{"runs": [...], "summary": {...}}``.
|
||||
|
||||
The ``summary`` averages each metric across runs. Its keys mirror
|
||||
MLflow's gateway benchmark (``avg_mean_ms`` / ``avg_p50_ms`` /
|
||||
``avg_p99_ms`` / ``avg_rps``) plus ``avg_p95_ms``, so the workspace ETL
|
||||
that flattens ``summary`` works unchanged.
|
||||
|
||||
:param results: One :class:`RunResult` per timed run (warmup excluded).
|
||||
:returns: A dict with a per-run ``runs`` list and an averaged
|
||||
``summary`` (empty ``summary`` when *results* is empty).
|
||||
"""
|
||||
runs = [_run_to_dict(r) for r in results]
|
||||
if not results:
|
||||
return {"runs": runs, "summary": {}}
|
||||
summary = {
|
||||
"avg_mean_ms": statistics.mean(r.mean_ms() for r in results),
|
||||
"avg_p50_ms": statistics.mean(r.percentile(50) for r in results),
|
||||
"avg_p95_ms": statistics.mean(r.percentile(95) for r in results),
|
||||
"avg_p99_ms": statistics.mean(r.percentile(99) for r in results),
|
||||
"avg_rps": statistics.mean(r.throughput for r in results),
|
||||
}
|
||||
return {"runs": runs, "summary": summary}
|
||||
|
||||
|
||||
def check_thresholds(
|
||||
results: list[RunResult],
|
||||
*,
|
||||
min_rps: float | None = None,
|
||||
max_p50_ms: float | None = None,
|
||||
max_p99_ms: float | None = None,
|
||||
) -> bool:
|
||||
"""Check averaged results against optional CI thresholds.
|
||||
|
||||
:param results: Timed runs for one journey.
|
||||
:param min_rps: Fail if average throughput is below this (req/s).
|
||||
:param max_p50_ms: Fail if average p50 latency exceeds this (ms).
|
||||
:param max_p99_ms: Fail if average p99 latency exceeds this (ms).
|
||||
:returns: ``True`` when every supplied threshold passes (vacuously
|
||||
true when none are supplied or *results* is empty).
|
||||
"""
|
||||
if not results:
|
||||
return True
|
||||
avg_rps = statistics.mean(r.throughput for r in results)
|
||||
avg_p50 = statistics.mean(r.percentile(50) for r in results)
|
||||
avg_p99 = statistics.mean(r.percentile(99) for r in results)
|
||||
passed = True
|
||||
|
||||
if min_rps is not None and avg_rps < min_rps:
|
||||
console.print(
|
||||
f" [red]THRESHOLD FAILED:[/red] avg throughput {avg_rps:.0f} req/s"
|
||||
f" < minimum {min_rps:.0f} req/s"
|
||||
)
|
||||
passed = False
|
||||
if max_p50_ms is not None and avg_p50 > max_p50_ms:
|
||||
console.print(
|
||||
f" [red]THRESHOLD FAILED:[/red] avg P50 {avg_p50:.1f} ms"
|
||||
f" > maximum {max_p50_ms:.1f} ms"
|
||||
)
|
||||
passed = False
|
||||
if max_p99_ms is not None and avg_p99 > max_p99_ms:
|
||||
console.print(
|
||||
f" [red]THRESHOLD FAILED:[/red] avg P99 {avg_p99:.1f} ms"
|
||||
f" > maximum {max_p99_ms:.1f} ms"
|
||||
)
|
||||
passed = False
|
||||
return passed
|
||||
|
||||
|
||||
def print_results(journey_name: str, results: list[RunResult]) -> None:
|
||||
"""Render per-run and averaged metrics for one journey as a rich table.
|
||||
|
||||
:param journey_name: Journey label used as the table title.
|
||||
:param results: Timed runs to display.
|
||||
"""
|
||||
table = Table(
|
||||
title=journey_name,
|
||||
show_header=True,
|
||||
header_style="bold cyan",
|
||||
box=None,
|
||||
padding=(0, 2),
|
||||
title_justify="left",
|
||||
)
|
||||
table.add_column("Run", style="dim", width=5)
|
||||
table.add_column("Mean ms", justify="right")
|
||||
table.add_column("P50 ms", justify="right")
|
||||
table.add_column("P95 ms", justify="right")
|
||||
table.add_column("P99 ms", justify="right")
|
||||
table.add_column("Max ms", justify="right")
|
||||
table.add_column("Req/s", justify="right")
|
||||
table.add_column("Failures", justify="right")
|
||||
|
||||
for i, r in enumerate(results):
|
||||
fail_str = f"[red]{r.n_failures}[/red]" if r.n_failures else "0"
|
||||
table.add_row(
|
||||
str(i + 1),
|
||||
f"{r.mean_ms():.1f}",
|
||||
f"{r.percentile(50):.1f}",
|
||||
f"{r.percentile(95):.1f}",
|
||||
f"{r.percentile(99):.1f}",
|
||||
f"{r.max_ms():.1f}",
|
||||
f"{r.throughput:.0f}",
|
||||
fail_str,
|
||||
)
|
||||
|
||||
if len(results) > 1:
|
||||
table.add_section()
|
||||
table.add_row(
|
||||
"[bold]avg[/bold]",
|
||||
f"[bold]{statistics.mean(r.mean_ms() for r in results):.1f}[/bold]",
|
||||
f"[bold]{statistics.mean(r.percentile(50) for r in results):.1f}[/bold]",
|
||||
f"[bold]{statistics.mean(r.percentile(95) for r in results):.1f}[/bold]",
|
||||
f"[bold]{statistics.mean(r.percentile(99) for r in results):.1f}[/bold]",
|
||||
f"[bold]{statistics.mean(r.max_ms() for r in results):.1f}[/bold]",
|
||||
f"[bold]{statistics.mean(r.throughput for r in results):.0f}[/bold]",
|
||||
"",
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
|
||||
combined: dict[str, int] = {}
|
||||
for r in results:
|
||||
for reason, count in r.failures.items():
|
||||
combined[reason] = combined.get(reason, 0) + count
|
||||
if combined:
|
||||
console.print(" [red]Failure breakdown:[/red]")
|
||||
for reason, count in sorted(combined.items(), key=lambda kv: -kv[1]):
|
||||
console.print(f" {reason}: {count}")
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Omnigent user-journey benchmark runner.
|
||||
|
||||
Boots a real ``omnigent server`` against a SQLite DB (no runner, no LLM),
|
||||
drives the selected HTTP journeys under load, prints per-journey latency /
|
||||
throughput tables, and writes a versioned JSON report. Exits non-zero if any
|
||||
supplied threshold is breached.
|
||||
|
||||
Runs in the project venv — it imports ``omnigent`` and ``tests._helpers`` and
|
||||
spawns the real server, so it is NOT a standalone PEP 723 script. Invoke with
|
||||
``--no-sync`` so ``uv`` uses the existing environment instead of rebuilding the
|
||||
project (which triggers a web-UI build that fails in a worktree)::
|
||||
|
||||
uv run --no-sync dev/benchmarks/omnigent/run.py
|
||||
uv run --no-sync dev/benchmarks/omnigent/run.py --journeys list_sessions,get_session
|
||||
uv run --no-sync dev/benchmarks/omnigent/run.py --requests 500 --concurrency 25 --runs 3
|
||||
uv run --no-sync dev/benchmarks/omnigent/run.py --output bench.json --max-p50-ms 25
|
||||
|
||||
The JSON is the contract consumed by the workspace Databricks ETL notebook —
|
||||
see ``README.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow ``uv run <path>`` (no package context) to import the sibling modules.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||
|
||||
from dev.benchmarks.omnigent.environment import BenchEnvironment
|
||||
from dev.benchmarks.omnigent.journeys import (
|
||||
ALL_JOURNEYS,
|
||||
Journey,
|
||||
resolve_journeys,
|
||||
run_latency,
|
||||
run_throughput,
|
||||
)
|
||||
from dev.benchmarks.omnigent.measure import (
|
||||
RunResult,
|
||||
aggregate,
|
||||
check_thresholds,
|
||||
console,
|
||||
print_results,
|
||||
)
|
||||
from dev.benchmarks.omnigent.schema import build_report
|
||||
|
||||
# Harness label stamped in the report: HTTP/DB journeys drive no agent turn;
|
||||
# runner journeys drive turns through the in-process openai-agents SDK harness.
|
||||
_HTTP_HARNESS = "http-only"
|
||||
_RUNNER_HARNESS = "openai-agents"
|
||||
|
||||
|
||||
def _backend_of(database_uri: str | None) -> str:
|
||||
"""Classify the DB URI into a coarse backend label for the report.
|
||||
|
||||
``None`` is the harness's throwaway SQLite temp file. Otherwise key off the
|
||||
URI scheme so the report (and the workspace dashboard) can group by backend.
|
||||
"""
|
||||
if database_uri is None or database_uri.startswith("sqlite"):
|
||||
return "sqlite"
|
||||
if database_uri.startswith("postgres"):
|
||||
return "postgres"
|
||||
return "other"
|
||||
|
||||
|
||||
def _effective_iterations(journey: Journey, requested: int) -> int:
|
||||
"""Clamp *requested* iterations down to the journey's ``max_iterations``.
|
||||
|
||||
Full-turn journeys cost ~1s+ per op and cap themselves so a large
|
||||
``--iterations`` (tuned for the millisecond HTTP journeys) doesn't overrun
|
||||
the CI time budget. The cap only ever lowers the count, never raises it.
|
||||
"""
|
||||
if journey.max_iterations is not None:
|
||||
return min(requested, journey.max_iterations)
|
||||
return requested
|
||||
|
||||
|
||||
async def _run_journey(
|
||||
journey: Journey, env: BenchEnvironment, args: argparse.Namespace
|
||||
) -> tuple[str, list[RunResult]]:
|
||||
"""Run one journey's timed runs, returning its report kind + per-run results.
|
||||
|
||||
A journey runs as throughput when ``--concurrency > 1`` and it is
|
||||
concurrency-safe; otherwise as sequential latency.
|
||||
"""
|
||||
as_throughput = args.concurrency > 1 and journey.concurrency_safe
|
||||
iterations = _effective_iterations(journey, args.iterations)
|
||||
results: list[RunResult] = []
|
||||
for _ in range(args.runs):
|
||||
if as_throughput:
|
||||
results.append(
|
||||
await run_throughput(
|
||||
journey,
|
||||
env,
|
||||
requests=args.requests,
|
||||
concurrency=args.concurrency,
|
||||
warmup=args.warmup,
|
||||
)
|
||||
)
|
||||
else:
|
||||
results.append(
|
||||
await run_latency(journey, env, iterations=iterations, warmup=args.warmup)
|
||||
)
|
||||
return ("throughput" if as_throughput else "latency"), results
|
||||
|
||||
|
||||
async def run_benchmark(args: argparse.Namespace) -> tuple[dict[str, object], bool]:
|
||||
"""Run all selected journeys and build the report.
|
||||
|
||||
:returns: ``(report, passed)`` where *passed* is ``False`` if any journey
|
||||
breached a supplied threshold.
|
||||
"""
|
||||
journeys = resolve_journeys(args.journeys)
|
||||
journey_results: dict[str, dict[str, object]] = {}
|
||||
passed = True
|
||||
backend = _backend_of(args.database_uri)
|
||||
|
||||
# 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.
|
||||
with_runner = any(j.needs_runner for j in journeys)
|
||||
harness = _RUNNER_HARNESS if with_runner else _HTTP_HARNESS
|
||||
|
||||
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)
|
||||
print_results(journey.name, results)
|
||||
block = aggregate(results)
|
||||
block["kind"] = kind
|
||||
block["backend"] = backend
|
||||
journey_results[journey.name] = block
|
||||
if not check_thresholds(
|
||||
results,
|
||||
min_rps=args.min_rps,
|
||||
max_p50_ms=args.max_p50_ms,
|
||||
max_p99_ms=args.max_p99_ms,
|
||||
):
|
||||
passed = False
|
||||
|
||||
config = {
|
||||
"iterations": args.iterations,
|
||||
"requests": args.requests,
|
||||
"concurrency": args.concurrency,
|
||||
"runs": args.runs,
|
||||
"warmup": args.warmup,
|
||||
"with_runner": with_runner,
|
||||
"backend": backend,
|
||||
}
|
||||
generated_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
report = build_report(
|
||||
journey_results,
|
||||
generated_at=generated_at,
|
||||
config=config,
|
||||
harness=harness,
|
||||
)
|
||||
return report, passed
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="omnigent-benchmark",
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--journeys",
|
||||
type=lambda s: [p.strip() for p in s.split(",") if p.strip()],
|
||||
default=None,
|
||||
metavar="A,B,C",
|
||||
help=f"Comma-separated journeys to run. Default: all ({', '.join(ALL_JOURNEYS)}).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--database-uri",
|
||||
default=None,
|
||||
metavar="URI",
|
||||
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",
|
||||
type=int,
|
||||
default=100,
|
||||
metavar="N",
|
||||
help="Sequential operations per latency run (default: 100).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--requests",
|
||||
type=int,
|
||||
default=500,
|
||||
metavar="N",
|
||||
help="Total operations per throughput run — used when --concurrency>1 (default: 500).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--concurrency",
|
||||
type=int,
|
||||
default=1,
|
||||
metavar="N",
|
||||
help="Max in-flight operations. >1 runs concurrency-safe journeys as "
|
||||
"throughput (default: 1 = sequential latency).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--runs",
|
||||
type=int,
|
||||
default=3,
|
||||
metavar="N",
|
||||
help="Timed runs per journey; results are per-run and averaged (default: 3).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--warmup",
|
||||
type=int,
|
||||
default=10,
|
||||
metavar="N",
|
||||
help="Warmup operations discarded before each run (default: 10).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=None,
|
||||
metavar="FILE",
|
||||
help="Write the JSON report to FILE (for CI artifact upload).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-rps",
|
||||
type=float,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help="Exit 1 if any journey's avg throughput falls below N req/s.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-p50-ms",
|
||||
type=float,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help="Exit 1 if any journey's avg P50 latency exceeds N ms.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-p99-ms",
|
||||
type=float,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help="Exit 1 if any journey's avg P99 latency exceeds N ms.",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(argv if argv is not None else sys.argv[1:])
|
||||
report, passed = asyncio.run(run_benchmark(args))
|
||||
if args.output is not None:
|
||||
args.output.write_text(json.dumps(report, indent=2))
|
||||
console.print(f"\n Results written to [cyan]{args.output}[/cyan]")
|
||||
if not passed:
|
||||
console.print("\n[red]One or more thresholds failed.[/red]")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,268 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"generated_at": "2026-07-08T18:30:00+00:00",
|
||||
"git_sha": "0000000000000000000000000000000000000000",
|
||||
"git_branch": "main",
|
||||
"host": {
|
||||
"platform": "macOS-15.5-arm64-arm-64bit",
|
||||
"python": "3.12.8",
|
||||
"cpu_count": 12
|
||||
},
|
||||
"harness": "http-only",
|
||||
"config": {
|
||||
"iterations": 100,
|
||||
"requests": 500,
|
||||
"concurrency": 1,
|
||||
"runs": 3,
|
||||
"warmup": 10,
|
||||
"with_runner": false,
|
||||
"backend": "sqlite"
|
||||
},
|
||||
"journeys": {
|
||||
"list_sessions": {
|
||||
"runs": [
|
||||
{
|
||||
"n_success": 100,
|
||||
"n_failures": 0,
|
||||
"failures": {},
|
||||
"wall_time_s": 0.6587757079978473,
|
||||
"mean_ms": 6.586994149838574,
|
||||
"p50_ms": 6.261250004172325,
|
||||
"p95_ms": 7.65325000975281,
|
||||
"p99_ms": 7.9127089702524245,
|
||||
"max_ms": 38.27937500318512,
|
||||
"rps": 151.79673261468648
|
||||
},
|
||||
{
|
||||
"n_success": 100,
|
||||
"n_failures": 0,
|
||||
"failures": {},
|
||||
"wall_time_s": 0.6238234580378048,
|
||||
"mean_ms": 6.237602901528589,
|
||||
"p50_ms": 6.126708001829684,
|
||||
"p95_ms": 7.152916979975998,
|
||||
"p99_ms": 7.425624993629754,
|
||||
"max_ms": 7.667875033803284,
|
||||
"rps": 160.30176280087855
|
||||
},
|
||||
{
|
||||
"n_success": 100,
|
||||
"n_failures": 0,
|
||||
"failures": {},
|
||||
"wall_time_s": 0.5675292499945499,
|
||||
"mean_ms": 5.674769564066082,
|
||||
"p50_ms": 5.528832960408181,
|
||||
"p95_ms": 6.237249996047467,
|
||||
"p99_ms": 7.393250009045005,
|
||||
"max_ms": 11.83562504593283,
|
||||
"rps": 176.20237194992208
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"avg_mean_ms": 6.166455538477749,
|
||||
"avg_p50_ms": 5.972263655470063,
|
||||
"avg_p95_ms": 7.014472328592092,
|
||||
"avg_p99_ms": 7.577194657642394,
|
||||
"avg_rps": 162.7669557884957
|
||||
},
|
||||
"kind": "latency",
|
||||
"backend": "sqlite"
|
||||
},
|
||||
"create_session": {
|
||||
"runs": [
|
||||
{
|
||||
"n_success": 100,
|
||||
"n_failures": 0,
|
||||
"failures": {},
|
||||
"wall_time_s": 2.4451411250047386,
|
||||
"mean_ms": 24.450668342760764,
|
||||
"p50_ms": 24.028874991927296,
|
||||
"p95_ms": 27.25041698431596,
|
||||
"p99_ms": 29.374166973866522,
|
||||
"max_ms": 29.56758299842477,
|
||||
"rps": 40.89743490769115
|
||||
},
|
||||
{
|
||||
"n_success": 100,
|
||||
"n_failures": 0,
|
||||
"failures": {},
|
||||
"wall_time_s": 2.498600291030016,
|
||||
"mean_ms": 24.985182073432952,
|
||||
"p50_ms": 24.459875014144927,
|
||||
"p95_ms": 28.391665953677148,
|
||||
"p99_ms": 29.0600000298582,
|
||||
"max_ms": 34.39550002804026,
|
||||
"rps": 40.02240788932922
|
||||
},
|
||||
{
|
||||
"n_success": 100,
|
||||
"n_failures": 0,
|
||||
"failures": {},
|
||||
"wall_time_s": 2.455423459003214,
|
||||
"mean_ms": 24.553418274153955,
|
||||
"p50_ms": 24.073333013802767,
|
||||
"p95_ms": 27.43383398046717,
|
||||
"p99_ms": 29.375000041909516,
|
||||
"max_ms": 29.430416005197912,
|
||||
"rps": 40.72617276394162
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"avg_mean_ms": 24.663089563449223,
|
||||
"avg_p50_ms": 24.187361006624997,
|
||||
"avg_p95_ms": 27.691972306153428,
|
||||
"avg_p99_ms": 29.269722348544747,
|
||||
"avg_rps": 40.548671853654
|
||||
},
|
||||
"kind": "latency",
|
||||
"backend": "sqlite"
|
||||
},
|
||||
"get_session": {
|
||||
"runs": [
|
||||
{
|
||||
"n_success": 100,
|
||||
"n_failures": 0,
|
||||
"failures": {},
|
||||
"wall_time_s": 0.5546917500323616,
|
||||
"mean_ms": 5.5460037814918905,
|
||||
"p50_ms": 5.360124981962144,
|
||||
"p95_ms": 6.925499998033047,
|
||||
"p99_ms": 7.144333969336003,
|
||||
"max_ms": 7.331291970331222,
|
||||
"rps": 180.28030882767922
|
||||
},
|
||||
{
|
||||
"n_success": 100,
|
||||
"n_failures": 0,
|
||||
"failures": {},
|
||||
"wall_time_s": 0.49645508296089247,
|
||||
"mean_ms": 4.963922067545354,
|
||||
"p50_ms": 4.782959003932774,
|
||||
"p95_ms": 5.978292028885335,
|
||||
"p99_ms": 6.7617910099215806,
|
||||
"max_ms": 6.881375040393323,
|
||||
"rps": 201.42809174919327
|
||||
},
|
||||
{
|
||||
"n_success": 100,
|
||||
"n_failures": 0,
|
||||
"failures": {},
|
||||
"wall_time_s": 0.460644083970692,
|
||||
"mean_ms": 4.605880451854318,
|
||||
"p50_ms": 4.526541975792497,
|
||||
"p95_ms": 5.18629199359566,
|
||||
"p99_ms": 5.445250018965453,
|
||||
"max_ms": 5.790999974124134,
|
||||
"rps": 217.08734244020465
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"avg_mean_ms": 5.0386021002971875,
|
||||
"avg_p50_ms": 4.889875320562472,
|
||||
"avg_p95_ms": 6.030028006838013,
|
||||
"avg_p99_ms": 6.450458332741012,
|
||||
"avg_rps": 199.59858100569238
|
||||
},
|
||||
"kind": "latency",
|
||||
"backend": "sqlite"
|
||||
},
|
||||
"load_conversation_history": {
|
||||
"runs": [
|
||||
{
|
||||
"n_success": 100,
|
||||
"n_failures": 0,
|
||||
"failures": {},
|
||||
"wall_time_s": 0.20811437495285645,
|
||||
"mean_ms": 2.080691678565927,
|
||||
"p50_ms": 2.037000027485192,
|
||||
"p95_ms": 2.5742079596966505,
|
||||
"p99_ms": 2.768124977592379,
|
||||
"max_ms": 2.784749958664179,
|
||||
"rps": 480.50501087516284
|
||||
},
|
||||
{
|
||||
"n_success": 100,
|
||||
"n_failures": 0,
|
||||
"failures": {},
|
||||
"wall_time_s": 0.19513549999101087,
|
||||
"mean_ms": 1.9509158097207546,
|
||||
"p50_ms": 1.9018329912796617,
|
||||
"p95_ms": 2.284207963384688,
|
||||
"p99_ms": 2.4481670116074383,
|
||||
"max_ms": 2.5021659675985575,
|
||||
"rps": 512.4644157757384
|
||||
},
|
||||
{
|
||||
"n_success": 100,
|
||||
"n_failures": 0,
|
||||
"failures": {},
|
||||
"wall_time_s": 0.19278304203180596,
|
||||
"mean_ms": 1.9274150469573215,
|
||||
"p50_ms": 1.8819589749909937,
|
||||
"p95_ms": 2.2150420118123293,
|
||||
"p99_ms": 2.2878749878145754,
|
||||
"max_ms": 2.316958038136363,
|
||||
"rps": 518.7178236532945
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"avg_mean_ms": 1.9863408450813342,
|
||||
"avg_p50_ms": 1.9402639979186158,
|
||||
"avg_p95_ms": 2.3578193116312227,
|
||||
"avg_p99_ms": 2.5013889923381307,
|
||||
"avg_rps": 503.8957501013986
|
||||
},
|
||||
"kind": "latency",
|
||||
"backend": "sqlite"
|
||||
},
|
||||
"search_sessions": {
|
||||
"runs": [
|
||||
{
|
||||
"n_success": 100,
|
||||
"n_failures": 0,
|
||||
"failures": {},
|
||||
"wall_time_s": 8.150427000015043,
|
||||
"mean_ms": 81.50338126753923,
|
||||
"p50_ms": 80.11816703947261,
|
||||
"p95_ms": 90.9090840141289,
|
||||
"p99_ms": 94.67683301772922,
|
||||
"max_ms": 96.44366696011275,
|
||||
"rps": 12.26929582950874
|
||||
},
|
||||
{
|
||||
"n_success": 100,
|
||||
"n_failures": 0,
|
||||
"failures": {},
|
||||
"wall_time_s": 8.152122708968818,
|
||||
"mean_ms": 81.5203430026304,
|
||||
"p50_ms": 79.57212498877198,
|
||||
"p95_ms": 95.20591603359208,
|
||||
"p99_ms": 98.80137501750141,
|
||||
"max_ms": 99.93629204109311,
|
||||
"rps": 12.266743714490682
|
||||
},
|
||||
{
|
||||
"n_success": 100,
|
||||
"n_failures": 0,
|
||||
"failures": {},
|
||||
"wall_time_s": 8.053999124967959,
|
||||
"mean_ms": 80.53909589187242,
|
||||
"p50_ms": 79.52124997973442,
|
||||
"p95_ms": 91.39445802429691,
|
||||
"p99_ms": 93.2748339837417,
|
||||
"max_ms": 94.79766699951142,
|
||||
"rps": 12.416192061654566
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"avg_mean_ms": 81.18760672068068,
|
||||
"avg_p50_ms": 79.73718066932634,
|
||||
"avg_p95_ms": 92.50315269067262,
|
||||
"avg_p99_ms": 95.58434733965744,
|
||||
"avg_rps": 12.317410535217997
|
||||
},
|
||||
"kind": "latency",
|
||||
"backend": "sqlite"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Benchmark report schema + metadata capture.
|
||||
|
||||
:func:`build_report` assembles the single JSON document the harness writes.
|
||||
Its per-journey ``summary`` + ``runs`` shape mirrors MLflow's gateway
|
||||
benchmark so the workspace ETL notebook flattens it unchanged — keyed by
|
||||
journey (and ``harness``) instead of ``backend``. Bump :data:`SCHEMA_VERSION`
|
||||
whenever the document's shape changes so the ETL can branch on it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import subprocess
|
||||
|
||||
# Incremented on any breaking change to the report document shape below.
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def _git(*args: str) -> str:
|
||||
"""Run ``git *args`` at the repo root, returning stripped stdout or ``""``.
|
||||
|
||||
Never raises: a missing git, detached checkout, or non-zero exit all
|
||||
surface as an empty string so a benchmark run outside a clean checkout
|
||||
still produces a valid report.
|
||||
"""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
return out.stdout.strip() if out.returncode == 0 else ""
|
||||
|
||||
|
||||
def git_sha() -> str:
|
||||
"""Return the current commit SHA, or ``""`` when unavailable."""
|
||||
return _git("rev-parse", "HEAD")
|
||||
|
||||
|
||||
def git_branch() -> str:
|
||||
"""Return the current branch name, or ``""`` when detached/unavailable."""
|
||||
return _git("rev-parse", "--abbrev-ref", "HEAD")
|
||||
|
||||
|
||||
def host_info() -> dict[str, object]:
|
||||
"""Capture coarse host facts for cross-machine result comparison."""
|
||||
import os
|
||||
|
||||
return {
|
||||
"platform": platform.platform(),
|
||||
"python": platform.python_version(),
|
||||
"cpu_count": os.cpu_count(),
|
||||
}
|
||||
|
||||
|
||||
def build_report(
|
||||
journey_results: dict[str, dict[str, object]],
|
||||
*,
|
||||
generated_at: str,
|
||||
config: dict[str, object],
|
||||
harness: str,
|
||||
) -> dict[str, object]:
|
||||
"""Assemble the full benchmark report document.
|
||||
|
||||
:param journey_results: Per-journey ``{"kind", "runs", "summary"}``
|
||||
blocks (each ``runs``/``summary`` produced by
|
||||
:func:`measure.aggregate`), keyed by journey name.
|
||||
:param generated_at: ISO-8601 timestamp stamped by the caller (kept out
|
||||
of this pure function so it stays deterministic under test).
|
||||
:param config: The run's knobs (iterations, requests, concurrency, runs,
|
||||
mock_llm) for provenance.
|
||||
:param harness: Harness driving full-turn journeys, e.g.
|
||||
``"openai-agents"``.
|
||||
:returns: The JSON-serializable report document.
|
||||
"""
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"generated_at": generated_at,
|
||||
"git_sha": git_sha(),
|
||||
"git_branch": git_branch(),
|
||||
"host": host_info(),
|
||||
"harness": harness,
|
||||
"config": config,
|
||||
"journeys": journey_results,
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Deterministic corpus seeder for the performance benchmark.
|
||||
|
||||
The v1 harness booted an empty DB, so the read journeys measured a best-case
|
||||
near-empty table. This seeds a sizeable, realistic corpus directly through the
|
||||
store API (no HTTP, no runner) so ``list_sessions`` / ``get_session`` /
|
||||
``load_conversation_history`` read a production-shaped volume.
|
||||
|
||||
Writes to the same DB URI the server later boots against; startup migrations
|
||||
are an idempotent no-op on an at-head DB. The seed is deterministic (fixed RNG,
|
||||
fixed counts) so the same config always yields the same corpus — which is what
|
||||
makes "seed once, reuse" sound. The reuse marker records the Alembic head read
|
||||
at seed time, so a corpus from an older schema is auto-reseeded (no manual
|
||||
revision bookkeeping).
|
||||
|
||||
Listable-corpus recipe, per session (the permission grant is the gotcha — the
|
||||
loopback server resolves every request to user ``"local"`` and
|
||||
``list_sessions`` filters by it):
|
||||
|
||||
1. ``create_session_with_agent`` — conversation + session-scoped agent row.
|
||||
2. ``permission_store.grant("local", sid, LEVEL_OWNER)`` — makes it listable.
|
||||
3. one batched ``append(sid, items)`` — user-role message items.
|
||||
|
||||
Run standalone::
|
||||
|
||||
uv run --no-sync dev/benchmarks/omnigent/seed.py \
|
||||
--database-uri sqlite:///tmp/bench.db --sessions 5000 --items-per-session 50
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow ``uv run <path>`` (no package context) to import omnigent + siblings.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||
|
||||
from omnigent.db.utils import _get_head_db_revision, generate_agent_id
|
||||
from omnigent.entities import MessageData, NewConversationItem
|
||||
from omnigent.server.auth import LEVEL_OWNER, RESERVED_USER_LOCAL
|
||||
from omnigent.stores.conversation_store.sqlalchemy_store import SqlAlchemyConversationStore
|
||||
from omnigent.stores.permission_store.sqlalchemy_store import SqlAlchemyPermissionStore
|
||||
|
||||
# Label key stamped on the first seeded session recording the corpus config, so
|
||||
# a later run can detect an existing (and matching) seed and skip re-seeding.
|
||||
_SEED_META_LABEL = "omni_bench_seed"
|
||||
|
||||
# Fixed identifiers so the corpus is byte-stable across runs at a given config.
|
||||
_AGENT_NAME = "bench-agent"
|
||||
_DEFAULT_SESSIONS = 5000
|
||||
_DEFAULT_ITEMS = 50
|
||||
_DEFAULT_RNG_SEED = 1234
|
||||
|
||||
# A pool of realistic-ish message fragments; the RNG assembles item text from
|
||||
# these so search_text has lexical variety without external data.
|
||||
_FRAGMENTS = (
|
||||
"investigate the failing migration",
|
||||
"the runner keeps disconnecting under load",
|
||||
"add pagination to the sessions endpoint",
|
||||
"why does the policy classifier time out",
|
||||
"refactor the conversation store append path",
|
||||
"benchmark the list endpoints against postgres",
|
||||
"the web UI drops the last streamed token",
|
||||
"trace the tunnel handshake for this runner id",
|
||||
"summarize the changes in this pull request",
|
||||
"reproduce the elicitation race on reconnect",
|
||||
)
|
||||
|
||||
|
||||
def _meta_value(sessions: int, items_per_session: int, rng_seed: int, head: str) -> str:
|
||||
"""Serialize the corpus config into the seed-marker label value.
|
||||
|
||||
Includes the Alembic *head* read at seed time, so a corpus seeded under an
|
||||
older schema auto-mismatches the current head and is reseeded — no
|
||||
hand-maintained revision constant.
|
||||
"""
|
||||
return f"sessions={sessions};items={items_per_session};rng={rng_seed};rev={head}"
|
||||
|
||||
|
||||
def _existing_seed_meta(conv: SqlAlchemyConversationStore) -> str | None:
|
||||
"""Return the seed-marker label value if a bench corpus already exists.
|
||||
|
||||
Looks up the most recent ``bench-agent`` session and reads its
|
||||
``omni_bench_seed`` label. ``None`` means no (recognizable) seed present.
|
||||
"""
|
||||
listing = conv.list_conversations(limit=1, agent_name=_AGENT_NAME)
|
||||
if not listing.data:
|
||||
return None
|
||||
marked = conv.get_conversation(listing.data[0].id)
|
||||
return marked.labels.get(_SEED_META_LABEL) if marked is not None else None
|
||||
|
||||
|
||||
def _make_items(rng: random.Random, count: int) -> list[NewConversationItem]:
|
||||
"""Build *count* deterministic user-role message items.
|
||||
|
||||
User-role only: assistant messages require an ``agent`` field the store
|
||||
only assigns after a real turn, and the seeded read path is role-agnostic.
|
||||
"""
|
||||
items: list[NewConversationItem] = []
|
||||
for i in range(count):
|
||||
text = f"{rng.choice(_FRAGMENTS)} (item {i})"
|
||||
items.append(
|
||||
NewConversationItem(
|
||||
type="message",
|
||||
response_id=f"resp_seed_{i}",
|
||||
data=MessageData(role="user", content=[{"type": "input_text", "text": text}]),
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def seed(
|
||||
db_uri: str,
|
||||
*,
|
||||
sessions: int = _DEFAULT_SESSIONS,
|
||||
items_per_session: int = _DEFAULT_ITEMS,
|
||||
rng_seed: int = _DEFAULT_RNG_SEED,
|
||||
reseed: bool = False,
|
||||
) -> int:
|
||||
"""Seed *sessions* sessions × *items_per_session* items into *db_uri*.
|
||||
|
||||
Idempotent: if a matching seed already exists (same config + schema
|
||||
revision) it is left untouched unless *reseed* is set. Constructing the
|
||||
store runs migrations to head on first init, so *db_uri* need not
|
||||
pre-exist.
|
||||
|
||||
:param db_uri: SQLAlchemy URI the server will also boot against, e.g.
|
||||
``"sqlite:///abs/bench.db"`` or ``"postgresql+psycopg://…"``.
|
||||
:param sessions: Number of listable sessions to create.
|
||||
:param items_per_session: Conversation items appended to each session.
|
||||
:param rng_seed: Seed for the deterministic text RNG.
|
||||
:param reseed: Seed even when a matching corpus is already present.
|
||||
:returns: The number of sessions created (0 when a matching seed is reused).
|
||||
"""
|
||||
conv = SqlAlchemyConversationStore(db_uri)
|
||||
perms = SqlAlchemyPermissionStore(db_uri)
|
||||
|
||||
# Read the current schema head at runtime (no DB contacted) and fold it into
|
||||
# the reuse marker, so a corpus from an older schema is auto-reseeded.
|
||||
head = _get_head_db_revision("sqlite:///:memory:")
|
||||
want = _meta_value(sessions, items_per_session, rng_seed, head)
|
||||
if not reseed:
|
||||
existing = _existing_seed_meta(conv)
|
||||
if existing == want:
|
||||
print(f"seed: matching corpus already present ({want}); skipping")
|
||||
return 0
|
||||
if existing is not None:
|
||||
print(f"seed: existing corpus differs ({existing!r} != {want!r}); pass --reseed")
|
||||
return 0
|
||||
|
||||
perms.ensure_user(RESERVED_USER_LOCAL)
|
||||
rng = random.Random(rng_seed)
|
||||
|
||||
last_sid = ""
|
||||
for s in range(sessions):
|
||||
created = conv.create_session_with_agent(
|
||||
agent_id=generate_agent_id(),
|
||||
agent_name=_AGENT_NAME,
|
||||
agent_bundle_location="bench/seed", # never validated on the read path
|
||||
agent_description=None,
|
||||
title=f"bench session {s}: {rng.choice(_FRAGMENTS)}",
|
||||
)
|
||||
sid = created.conversation.id
|
||||
last_sid = sid
|
||||
perms.grant(RESERVED_USER_LOCAL, sid, LEVEL_OWNER)
|
||||
if items_per_session:
|
||||
conv.append(sid, _make_items(rng, items_per_session))
|
||||
if sessions >= 100 and s % (sessions // 10) == 0 and s:
|
||||
print(f"seed: {s}/{sessions} sessions")
|
||||
|
||||
# Stamp the corpus config on the LAST (newest) session — that's the one
|
||||
# ``_existing_seed_meta``'s default desc listing returns, so the reuse
|
||||
# check finds it regardless of corpus size.
|
||||
if last_sid:
|
||||
conv.set_labels(last_sid, {_SEED_META_LABEL: want})
|
||||
|
||||
print(f"seed: created {sessions} sessions × {items_per_session} items ({want})")
|
||||
return sessions
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="omnigent-benchmark-seed",
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--database-uri",
|
||||
metavar="URI",
|
||||
help="DB to seed. Required unless --print-head.",
|
||||
)
|
||||
parser.add_argument("--sessions", type=int, default=_DEFAULT_SESSIONS, metavar="N")
|
||||
parser.add_argument("--items-per-session", type=int, default=_DEFAULT_ITEMS, metavar="N")
|
||||
parser.add_argument("--rng-seed", type=int, default=_DEFAULT_RNG_SEED, metavar="N")
|
||||
parser.add_argument(
|
||||
"--reseed",
|
||||
action="store_true",
|
||||
help="Seed even if a matching corpus is already present.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--print-head",
|
||||
action="store_true",
|
||||
help="Print the repo's Alembic head revision and exit (drift-check helper).",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(argv if argv is not None else sys.argv[1:])
|
||||
if args.print_head:
|
||||
print(_get_head_db_revision("sqlite:///:memory:"))
|
||||
return 0
|
||||
if not args.database_uri:
|
||||
print("seed: --database-uri is required (unless --print-head)", file=sys.stderr)
|
||||
return 2
|
||||
seed(
|
||||
args.database_uri,
|
||||
sessions=args.sessions,
|
||||
items_per_session=args.items_per_session,
|
||||
rng_seed=args.rng_seed,
|
||||
reseed=args.reseed,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Generated
+1169
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "omnidev"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Per-repo dev pod supervisor TUI for the Omnigent repo"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "omnidev"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
crossterm = "0.28"
|
||||
ratatui = "0.29"
|
||||
ansi-to-tui = "7"
|
||||
notify = "8"
|
||||
notify-debouncer-full = "0.5"
|
||||
libc = "0.2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
tokio = { version = "1", features = [
|
||||
"rt-multi-thread",
|
||||
"macros",
|
||||
"process",
|
||||
"io-util",
|
||||
"net",
|
||||
"time",
|
||||
"sync",
|
||||
"signal",
|
||||
] }
|
||||
if-addrs = "0.15"
|
||||
@@ -0,0 +1,177 @@
|
||||
# omnidev
|
||||
|
||||
Dev tooling for Omnigent, in one binary with two independent capabilities:
|
||||
|
||||
1. A per-repo dev **pod supervisor** (bare `omnidev`) — the default.
|
||||
2. **Install management** (`omnidev install`/`update`/`check`) — install and
|
||||
keep a git-based omnigent up to date. See
|
||||
[Managing your omnigent install](#managing-your-omnigent-install). These
|
||||
subcommands need no checkout and run anywhere.
|
||||
|
||||
## Pod supervisor
|
||||
|
||||
A per-repo dev **pod** supervisor, as a single long-running terminal UI. It
|
||||
replaces the three-terminal local dev flow (`omnigent server`, `omnigent host`,
|
||||
`npm run dev`) with one process that:
|
||||
|
||||
- runs each checkout in an **isolated pod** — its own state dir, database,
|
||||
artifacts, logs, and auto-allocated ports — so multiple worktrees never
|
||||
collide;
|
||||
- **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`;
|
||||
the frontend self-reloads through Vite HMR;
|
||||
- gives you **scrollable per-process log panes** plus a combined view.
|
||||
|
||||
## Build & run
|
||||
|
||||
Requires the repo's usual dev prerequisites (`uv` for Python, `npm` for the
|
||||
web UI) plus a Rust toolchain.
|
||||
|
||||
```bash
|
||||
cd dev/omnidev
|
||||
cargo run # launches the TUI for the surrounding checkout
|
||||
```
|
||||
|
||||
Run it from anywhere inside the checkout — it walks up to the repo root
|
||||
(the `.jj`/`.git` marker) and requires `omnigent/` and
|
||||
`web/` to be present. Build a release binary with `cargo build --release`
|
||||
(lands at `target/release/omnidev`).
|
||||
|
||||
## What it starts
|
||||
|
||||
| Process | Command | Notes |
|
||||
|---|---|---|
|
||||
| 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`
|
||||
in `web/` when needed — `node_modules/` is missing, or `package.json` /
|
||||
`package-lock.json` is newer than it — so a fresh checkout or a new dependency
|
||||
doesn't make Vite fail its dependency scan. Output streams into the `vite` pane.
|
||||
|
||||
Open the UI at the `ui` URL shown in the header (the Vite dev server).
|
||||
|
||||
## Isolation
|
||||
|
||||
Only Omnigent's own state is isolated per pod — enough that concurrent pods
|
||||
never share a database, server pidfile, or `config.yaml` — via
|
||||
`OMNIGENT_DATA_DIR`, `OMNIGENT_DATABASE_URI`, `OMNIGENT_URL`, and
|
||||
`OMNIGENT_CONFIG_HOME`. Everything else (your real `HOME`, credentials, and
|
||||
uv/npm caches) is inherited, because the agents Omnigent runs need it. This is
|
||||
deliberately lighter than the hermetic `scripts/backend-smoke.sh` sandbox,
|
||||
which repoints `HOME`/`XDG_*` to touch nothing real.
|
||||
|
||||
Each pod gets its own `config.yaml` under `<pod>/config/`, pointed to by
|
||||
`OMNIGENT_CONFIG_HOME`. On first create it's **seeded** from your real
|
||||
`~/.omnigent/config.yaml` (if present) so the pod works out of the box — it
|
||||
keeps your providers — after which the two are independent: server-config edits
|
||||
inside a pod (via the UI or `omnigent config`) don't touch your real config.
|
||||
`--clean` wipes the pod dir, so the next run re-seeds from your real config.
|
||||
|
||||
The pod dir defaults to
|
||||
`${XDG_CACHE_HOME:-~/.cache}/omnidev/<repo-name>-<hash>/`, keyed to the
|
||||
canonical checkout path. Per-process logs are written through to
|
||||
`<pod>/logs/{server,host,vite}.log` for inspection outside the TUI.
|
||||
|
||||
## Options
|
||||
|
||||
```
|
||||
--server-port <N> Force the backend port (default: probe from 6767)
|
||||
--vite-port <N> Force the Vite port (default: probe from 5173)
|
||||
--vite-host <ADDR> Vite bind host (default: 127.0.0.1; use 0.0.0.0 for LAN access)
|
||||
--trust-lan-origins Trust this machine's LAN origins (for device testing)
|
||||
--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
|
||||
```
|
||||
|
||||
`--vite-host 0.0.0.0` exposes the Vite dev server on all interfaces for device
|
||||
testing. Vite still proxies API traffic to the pod backend through `127.0.0.1`.
|
||||
|
||||
### Testing from a phone or tablet
|
||||
|
||||
`--vite-host 0.0.0.0` alone lets a device load the UI, but the backend runs in
|
||||
single-user local mode, where its CSRF/CSWSH guard trusts only loopback
|
||||
origins. A device loads the UI at `http://<your-lan-ip>:<vite-port>`, so its
|
||||
browser stamps that non-loopback origin on every request — and the guard then
|
||||
rejects multipart uploads (403) and refuses the live WebSocket stream.
|
||||
|
||||
`--trust-lan-origins` fixes that: omnidev enumerates this machine's LAN IPv4
|
||||
addresses and trusts the matching `http://<ip>:<vite-port>` origins via the
|
||||
server's `OMNIGENT_WS_ALLOWED_ORIGINS` allowlist (merged with any value you
|
||||
already export). It stays exact-match — only those origins are trusted, nothing
|
||||
is disabled — so it's for dev pods, not deployed servers. The trusted origins
|
||||
are printed in the combined log at startup.
|
||||
|
||||
```bash
|
||||
omnidev --vite-host 0.0.0.0 --trust-lan-origins
|
||||
```
|
||||
|
||||
This covers IPv4 LAN addresses; mDNS `.local` hostnames and HTTPS origins are
|
||||
not auto-trusted (add those to `OMNIGENT_WS_ALLOWED_ORIGINS` yourself).
|
||||
|
||||
## Keys
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `1` / `2` / `3` / `0` | Focus server / host / vite / combined pane |
|
||||
| `Tab` | Cycle panes |
|
||||
| `↑` `↓` `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 |
|
||||
| `q` / `Ctrl-C` | Quit and tear down all processes |
|
||||
|
||||
## Managing your omnigent install
|
||||
|
||||
For people who *run* omnigent (installed from git via `uv tool install`) rather
|
||||
than develop it. This wraps the fiddly PEP 508 install syntax and adds a daily
|
||||
update check — filling a gap, since omnigent's own update notice only works for
|
||||
PyPI-wheel installs and skips git installs.
|
||||
|
||||
These subcommands manage the global tool and work from **any directory** (no
|
||||
checkout needed).
|
||||
|
||||
```
|
||||
omnidev install # uv tool install omnigent from git (databricks extra, main)
|
||||
omnidev update # reinstall the latest of the tracked ref/extras
|
||||
omnidev check # check for an update; prompt to update on a TTY
|
||||
omnidev refresh # refresh the check cache from the network (usually detached)
|
||||
omnidev shell-hook # print the daily-check snippet for your shell rc
|
||||
```
|
||||
|
||||
`install` options: `--ref <branch/tag/sha>` (default `main`), `--extra <name>`
|
||||
(repeatable; defaults to `databricks`), `--no-default-extra` (install with no
|
||||
extras), `--repo <url>`. The choice is saved to
|
||||
`${XDG_CONFIG_HOME:-~/.config}/omnidev/install.toml` so `update` reuses it.
|
||||
|
||||
Installing from git **builds the web UI from source**, so Node 22+/npm must be
|
||||
on PATH (the PyPI wheel ships the UI prebuilt; the git install does not).
|
||||
`omnidev install` fails early with a clear message if `uv` or `npm` is missing.
|
||||
|
||||
### Daily update check
|
||||
|
||||
Append the hook to your shell rc once to be told, at most once a day, when a
|
||||
newer `main` commit is available — and be offered to update on the spot:
|
||||
|
||||
```bash
|
||||
omnidev shell-hook >> ~/.zshrc # or ~/.bashrc
|
||||
```
|
||||
|
||||
The snippet itself guards on `command -v omnidev`, so it's a no-op in shells
|
||||
where omnidev isn't on PATH — nothing to fail. (Appending the snippet is
|
||||
preferred over `eval "$(omnidev shell-hook)"`: the latter would run omnidev on
|
||||
every shell startup and print a "command not found" error whenever omnidev is
|
||||
absent.)
|
||||
|
||||
On each interactive shell it runs `omnidev check --quiet`, which reads a cached
|
||||
result (`${XDG_CACHE_HOME:-~/.cache}/omnidev/omnigent-check.json`) and, when
|
||||
stale (>24h), refreshes it in a detached background process — so shell startup
|
||||
never blocks on the network. When a newer commit is available it prints a notice
|
||||
and, on a terminal, prompts `Update omnigent now? [y/N]`; on yes it runs
|
||||
`omnidev update` in the foreground. Declining suppresses that same commit until a
|
||||
newer one lands. Set `OMNIGENT_NO_UPDATE_CHECK` in your environment if you want
|
||||
to silence omnigent's own separate notice.
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Manage the user's git-based omnigent installation via `uv tool install`.
|
||||
//!
|
||||
//! None of this needs a local checkout: it drives `uv` and reads the installed
|
||||
//! tool's metadata, and any git call targets the remote.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::paths;
|
||||
|
||||
pub const DEFAULT_REPO: &str = "https://github.com/omnigent-ai/omnigent.git";
|
||||
pub const DEFAULT_REF: &str = "main";
|
||||
pub const DEFAULT_EXTRA: &str = "databricks";
|
||||
const PYTHON_VERSION: &str = "3.12";
|
||||
|
||||
/// Durable record of how the user wants omnigent installed. Persisted so
|
||||
/// `update` reinstalls the same repo/ref/extras without re-specifying them.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InstallConfig {
|
||||
pub repo: String,
|
||||
#[serde(rename = "ref")]
|
||||
pub git_ref: String,
|
||||
pub extras: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for InstallConfig {
|
||||
fn default() -> Self {
|
||||
InstallConfig {
|
||||
repo: DEFAULT_REPO.to_string(),
|
||||
git_ref: DEFAULT_REF.to_string(),
|
||||
extras: vec![DEFAULT_EXTRA.to_string()],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InstallConfig {
|
||||
pub fn load() -> Result<Option<InstallConfig>> {
|
||||
let path = paths::install_config_path()?;
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => Ok(Some(
|
||||
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?,
|
||||
)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = paths::install_config_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
let text = toml::to_string(self).context("serializing install config")?;
|
||||
std::fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The PEP 508 install spec, e.g.
|
||||
/// `omnigent[databricks] @ git+https://github.com/omnigent-ai/omnigent.git@main`.
|
||||
/// With no extras it collapses to the bare `git+<repo>@<ref>` URL.
|
||||
pub fn spec(&self) -> String {
|
||||
let source = format!("git+{}@{}", self.repo, self.git_ref);
|
||||
if self.extras.is_empty() {
|
||||
source
|
||||
} else {
|
||||
format!("omnigent[{}] @ {}", self.extras.join(","), source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fail early with a clear message if the toolchain a git install needs is
|
||||
/// missing. Installing from git builds the web UI from source (Node/npm),
|
||||
/// unlike the PyPI wheel which ships it prebuilt.
|
||||
fn preflight() -> Result<()> {
|
||||
if which("uv").is_none() {
|
||||
bail!("`uv` is not on PATH. Install it first: https://docs.astral.sh/uv/");
|
||||
}
|
||||
if which("npm").is_none() {
|
||||
bail!(
|
||||
"`npm` is not on PATH. Installing omnigent from git builds the web UI \
|
||||
from source and needs Node 22+/npm. Install Node, then retry."
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install omnigent from git per `config`. `reinstall` forces uv past its cache
|
||||
/// so a moving ref (e.g. `main`) actually re-resolves.
|
||||
pub fn run_uv_install(config: &InstallConfig, reinstall: bool) -> Result<()> {
|
||||
preflight()?;
|
||||
let spec = config.spec();
|
||||
|
||||
let mut cmd = Command::new("uv");
|
||||
cmd.args(["tool", "install", "--force", "--python", PYTHON_VERSION]);
|
||||
if reinstall {
|
||||
cmd.arg("--reinstall");
|
||||
}
|
||||
cmd.arg(&spec);
|
||||
|
||||
eprintln!("omnidev: uv tool install {spec}");
|
||||
let status = cmd
|
||||
.status()
|
||||
.context("running `uv tool install` (is uv installed?)")?;
|
||||
if !status.success() {
|
||||
bail!("`uv tool install` failed ({status})");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `install` subcommand: persist intent, install, then record the resolved sha.
|
||||
pub fn install(config: &InstallConfig) -> Result<()> {
|
||||
config.save()?;
|
||||
run_uv_install(config, false)?;
|
||||
record_installed_sha(config);
|
||||
println!("omnidev: installed omnigent ({})", config.spec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `update` subcommand: reinstall the latest of the persisted ref/extras. Falls
|
||||
/// back to defaults when no config has been written yet.
|
||||
pub fn update() -> Result<()> {
|
||||
let config = InstallConfig::load()?.unwrap_or_default();
|
||||
config.save()?;
|
||||
run_uv_install(&config, true)?;
|
||||
record_installed_sha(&config);
|
||||
println!("omnidev: updated omnigent ({})", config.spec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// After a successful install, capture the remote sha of the tracked ref and
|
||||
/// stash it in the cache so `check` has a baseline even before the dist-info
|
||||
/// reader runs. Best-effort — failures here never fail the install.
|
||||
fn record_installed_sha(config: &InstallConfig) {
|
||||
if let Some(sha) = crate::update_check::remote_sha(&config.repo, &config.git_ref) {
|
||||
let _ = crate::update_check::set_installed_sha(&sha);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the commit the installed omnigent tool was built from, via its PEP 610
|
||||
/// `direct_url.json`. Returns `None` for a non-VCS install or when uv/metadata
|
||||
/// can't be read. Never touches the working directory.
|
||||
pub fn installed_commit() -> Option<String> {
|
||||
let dir = uv_tool_dir()?;
|
||||
// …/omnigent/**/omnigent-*.dist-info/direct_url.json
|
||||
let omnigent_root = dir.join("omnigent");
|
||||
let dist_info = find_dist_info(&omnigent_root)?;
|
||||
let text = std::fs::read_to_string(dist_info.join("direct_url.json")).ok()?;
|
||||
let value: serde_json::Value = serde_json::from_str(&text).ok()?;
|
||||
value
|
||||
.get("vcs_info")?
|
||||
.get("commit_id")?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn uv_tool_dir() -> Option<PathBuf> {
|
||||
let output = Command::new("uv").args(["tool", "dir"]).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let path = String::from_utf8(output.stdout).ok()?;
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PathBuf::from(trimmed))
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the `omnigent-*.dist-info` dir under a uv tool's environment. uv lays
|
||||
/// tools out as `<tool>/lib/pythonX.Y/site-packages/<pkg>-<ver>.dist-info`, so
|
||||
/// we walk rather than hardcode the python version.
|
||||
fn find_dist_info(root: &std::path::Path) -> Option<PathBuf> {
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name.starts_with("omnigent-") && name.ends_with(".dist-info") {
|
||||
return Some(path);
|
||||
}
|
||||
stack.push(path);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Locate an executable on PATH (portable `which`, no external dep).
|
||||
fn which(program: &str) -> Option<PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
for dir in std::env::split_paths(&path) {
|
||||
let candidate = dir.join(program);
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! LAN origin discovery for device testing.
|
||||
//!
|
||||
//! When Vite binds to `0.0.0.0` (`--vite-host 0.0.0.0`), a phone or tablet on
|
||||
//! the same network loads the UI at `http://<lan-ip>:<vite-port>`. Its browser
|
||||
//! stamps that non-loopback address as the `Origin` on every request. The
|
||||
//! backend runs in local single-user mode, where the origin guard
|
||||
//! (`omnigent.server.ws_origin.origin_allowed`) admits only loopback origins —
|
||||
//! so multipart uploads get a 403 and the WebSocket stream is refused.
|
||||
//!
|
||||
//! `--trust-lan-origins` closes that gap by enumerating this machine's LAN
|
||||
//! IPv4 addresses and handing the server the matching `http://<ip>:<port>`
|
||||
//! origins via `OMNIGENT_WS_ALLOWED_ORIGINS` — the server's own exact-match
|
||||
//! allowlist. It stays exact-match (no security disable): only the origins we
|
||||
//! name are trusted.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
/// Whether an IPv4 address is a usable LAN address to trust as an origin.
|
||||
///
|
||||
/// Keeps private (RFC 1918) and link-local (169.254/16) addresses — the ones a
|
||||
/// device on the same network actually reaches this machine by. Drops loopback
|
||||
/// (already trusted), unspecified (`0.0.0.0`), broadcast, documentation, and
|
||||
/// multicast, none of which a real device browses to.
|
||||
fn is_lan_ipv4(ip: &Ipv4Addr) -> bool {
|
||||
(ip.is_private() || ip.is_link_local())
|
||||
&& !ip.is_loopback()
|
||||
&& !ip.is_unspecified()
|
||||
&& !ip.is_broadcast()
|
||||
&& !ip.is_multicast()
|
||||
}
|
||||
|
||||
/// Build the `http://<ip>:<port>` origins to trust for a given set of LAN
|
||||
/// IPv4 addresses.
|
||||
///
|
||||
/// Split out from interface enumeration so the origin-shaping (which is all we
|
||||
/// assert on) is testable without touching the host's real interfaces. The
|
||||
/// input is deduplicated and the output is sorted for a stable env value.
|
||||
fn origins_for_ips(ips: impl IntoIterator<Item = Ipv4Addr>, vite_port: u16) -> Vec<String> {
|
||||
let mut origins: Vec<String> = ips
|
||||
.into_iter()
|
||||
.filter(is_lan_ipv4)
|
||||
.map(|ip| format!("http://{ip}:{vite_port}"))
|
||||
.collect();
|
||||
origins.sort();
|
||||
origins.dedup();
|
||||
origins
|
||||
}
|
||||
|
||||
/// Discover the `http://<lan-ip>:<vite-port>` origins for this machine's LAN
|
||||
/// interfaces.
|
||||
///
|
||||
/// Returns an empty vector when no LAN interface is found (e.g. offline) — the
|
||||
/// caller then simply trusts nothing extra rather than failing. Interface
|
||||
/// enumeration errors are treated the same way: LAN trust is a convenience, so
|
||||
/// a lookup failure must not block the pod from starting.
|
||||
pub fn trusted_lan_origins(vite_port: u16) -> Vec<String> {
|
||||
let ips = match if_addrs::get_if_addrs() {
|
||||
Ok(ifaces) => ifaces
|
||||
.into_iter()
|
||||
.filter_map(|iface| match iface.addr.ip() {
|
||||
std::net::IpAddr::V4(v4) => Some(v4),
|
||||
std::net::IpAddr::V6(_) => None,
|
||||
}),
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
origins_for_ips(ips, vite_port)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_private_and_link_local_drops_loopback_and_public() {
|
||||
assert!(is_lan_ipv4(&Ipv4Addr::new(192, 168, 1, 42)));
|
||||
assert!(is_lan_ipv4(&Ipv4Addr::new(10, 0, 0, 5)));
|
||||
assert!(is_lan_ipv4(&Ipv4Addr::new(172, 16, 3, 9)));
|
||||
assert!(is_lan_ipv4(&Ipv4Addr::new(169, 254, 10, 1)));
|
||||
|
||||
assert!(!is_lan_ipv4(&Ipv4Addr::new(127, 0, 0, 1)));
|
||||
assert!(!is_lan_ipv4(&Ipv4Addr::new(0, 0, 0, 0)));
|
||||
assert!(!is_lan_ipv4(&Ipv4Addr::new(8, 8, 8, 8)));
|
||||
assert!(!is_lan_ipv4(&Ipv4Addr::new(255, 255, 255, 255)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_http_origins_with_the_vite_port() {
|
||||
let origins = origins_for_ips([Ipv4Addr::new(192, 168, 1, 42)], 5173);
|
||||
assert_eq!(origins, vec!["http://192.168.1.42:5173"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_and_sorts_and_dedups() {
|
||||
let origins = origins_for_ips(
|
||||
[
|
||||
Ipv4Addr::new(10, 0, 0, 9),
|
||||
Ipv4Addr::new(127, 0, 0, 1), // loopback dropped
|
||||
Ipv4Addr::new(8, 8, 8, 8), // public dropped
|
||||
Ipv4Addr::new(192, 168, 1, 5),
|
||||
Ipv4Addr::new(10, 0, 0, 9), // duplicate collapsed
|
||||
],
|
||||
8080,
|
||||
);
|
||||
assert_eq!(
|
||||
origins,
|
||||
vec!["http://10.0.0.9:8080", "http://192.168.1.5:8080"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_lan_interfaces_yields_no_origins() {
|
||||
assert!(origins_for_ips([Ipv4Addr::new(127, 0, 0, 1)], 5173).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Single-instance guard per pod.
|
||||
//!
|
||||
//! Two omnidev runs in the same checkout resolve to the same pod dir (the dir
|
||||
//! is keyed to the canonical repo root), so their processes would fight over
|
||||
//! the same ports and state. An advisory `flock` on a file in the pod dir lets
|
||||
//! only the first in. The lock is held for the process lifetime and released
|
||||
//! by the OS on exit or crash — no stale-file cleanup needed.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
/// An acquired pod lock. Dropping it (on process exit) releases the flock.
|
||||
pub struct PodLock {
|
||||
_file: File,
|
||||
}
|
||||
|
||||
/// Try to take the pod's exclusive lock. Returns an error naming the pod dir if
|
||||
/// another omnidev already holds it.
|
||||
pub fn acquire(pod_dir: &Path) -> Result<PodLock> {
|
||||
let path = pod_dir.join("omnidev.lock");
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.open(&path)
|
||||
.with_context(|| format!("opening lock file {}", path.display()))?;
|
||||
|
||||
// Non-blocking exclusive lock: EWOULDBLOCK means a peer holds it.
|
||||
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if rc != 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::EWOULDBLOCK) {
|
||||
bail!(
|
||||
"another omnidev is already running for this checkout (pod {}). \
|
||||
Quit it first, or run in a different worktree.",
|
||||
pod_dir.display()
|
||||
);
|
||||
}
|
||||
return Err(err).with_context(|| format!("locking {}", path.display()));
|
||||
}
|
||||
|
||||
Ok(PodLock { _file: file })
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Per-process bounded log buffers with write-through to disk.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
const MAX_LINES: usize = 5000;
|
||||
|
||||
/// A bounded ring buffer of log lines for one channel, mirrored to a file so
|
||||
/// the full session output survives for later inspection (`tail`, editor).
|
||||
pub struct LogBuffer {
|
||||
lines: VecDeque<String>,
|
||||
file: Option<File>,
|
||||
/// Monotonic count of lines ever appended — lets panes detect growth for
|
||||
/// follow-tail without diffing the buffer.
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
impl LogBuffer {
|
||||
pub fn new(path: &Path) -> Self {
|
||||
let file = OpenOptions::new().create(true).append(true).open(path).ok();
|
||||
LogBuffer {
|
||||
lines: VecDeque::with_capacity(MAX_LINES),
|
||||
file,
|
||||
total: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory only channel (e.g. the synthetic "omnidev" event log).
|
||||
pub fn memory() -> Self {
|
||||
LogBuffer {
|
||||
lines: VecDeque::with_capacity(256),
|
||||
file: None,
|
||||
total: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, line: impl Into<String>) {
|
||||
let line = line.into();
|
||||
if let Some(f) = self.file.as_mut() {
|
||||
let _ = writeln!(f, "{line}");
|
||||
}
|
||||
if self.lines.len() == MAX_LINES {
|
||||
self.lines.pop_front();
|
||||
}
|
||||
self.lines.push_back(line);
|
||||
self.total = self.total.saturating_add(1);
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.lines.clear();
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &String> {
|
||||
self.lines.iter()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! omnidev — dev tooling for Omnigent.
|
||||
//!
|
||||
//! Two independent capabilities in one binary:
|
||||
//! - **pod supervisor** (bare `omnidev`): manages an isolated dev instance for
|
||||
//! the current checkout — server/host/vite, restarting the backend on Python
|
||||
//! changes while Vite handles frontend HMR.
|
||||
//! - **install management** (`omnidev install`/`update`/`check`/…): install and
|
||||
//! keep a git-based omnigent up to date. These need no checkout and run
|
||||
//! anywhere.
|
||||
|
||||
mod install;
|
||||
mod lan;
|
||||
mod lock;
|
||||
mod logs;
|
||||
mod paths;
|
||||
mod pod;
|
||||
mod ports;
|
||||
mod process;
|
||||
mod shellhook;
|
||||
mod state;
|
||||
mod supervisor;
|
||||
mod tui;
|
||||
mod update_check;
|
||||
mod watcher;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use install::InstallConfig;
|
||||
use pod::Pod;
|
||||
use ports::Ports;
|
||||
use state::Shared;
|
||||
use supervisor::{Cmd, Supervisor};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "omnidev", about = "Dev tooling for Omnigent", version)]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
|
||||
#[command(flatten)]
|
||||
run: RunArgs,
|
||||
}
|
||||
|
||||
/// Flags for the default (no-subcommand) pod-supervisor run.
|
||||
#[derive(clap::Args, Debug)]
|
||||
struct RunArgs {
|
||||
/// Force the backend server port (default: probe from 6767).
|
||||
#[arg(long)]
|
||||
server_port: Option<u16>,
|
||||
|
||||
/// Force the Vite dev-server port (default: probe from 5173).
|
||||
#[arg(long)]
|
||||
vite_port: Option<u16>,
|
||||
|
||||
/// Vite dev-server bind host (default: 127.0.0.1; use 0.0.0.0 for LAN access).
|
||||
#[arg(long, default_value = "127.0.0.1")]
|
||||
vite_host: String,
|
||||
|
||||
/// Trust this machine's LAN origins so a phone/tablet on the same network
|
||||
/// can use the UI (uploads + live stream). Pairs with `--vite-host 0.0.0.0`.
|
||||
#[arg(long)]
|
||||
trust_lan_origins: bool,
|
||||
|
||||
/// Use this pod directory instead of the per-repo default.
|
||||
#[arg(long)]
|
||||
pod_dir: Option<PathBuf>,
|
||||
|
||||
/// Do not start the Vite frontend (backend + host only).
|
||||
#[arg(long)]
|
||||
no_vite: bool,
|
||||
|
||||
/// Wipe the pod directory before starting.
|
||||
#[arg(long)]
|
||||
clean: bool,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Command {
|
||||
/// Install omnigent from git (defaults to the databricks extra, main).
|
||||
Install {
|
||||
/// Git ref (branch/tag/sha) to track.
|
||||
#[arg(long, default_value = install::DEFAULT_REF)]
|
||||
r#ref: String,
|
||||
/// Extra to include (repeatable). Defaults to `databricks`.
|
||||
#[arg(long = "extra")]
|
||||
extras: Vec<String>,
|
||||
/// Omit the default databricks extra (install with no extras).
|
||||
#[arg(long)]
|
||||
no_default_extra: bool,
|
||||
/// Git repo URL.
|
||||
#[arg(long, default_value = install::DEFAULT_REPO)]
|
||||
repo: String,
|
||||
},
|
||||
/// Reinstall the latest of the tracked ref/extras.
|
||||
Update,
|
||||
/// Check for an omnigent update (the shell hook calls this).
|
||||
Check {
|
||||
/// Print nothing when already up to date.
|
||||
#[arg(long)]
|
||||
quiet: bool,
|
||||
},
|
||||
/// Refresh the update-check cache from the network (usually run detached).
|
||||
Refresh,
|
||||
/// Print a shell snippet to eval from .zshrc/.bashrc for daily checks.
|
||||
ShellHook,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
// Install-management subcommands manage a global tool and must work from
|
||||
// anywhere — dispatch them before any checkout discovery.
|
||||
match args.command {
|
||||
Some(Command::Install {
|
||||
r#ref,
|
||||
extras,
|
||||
no_default_extra,
|
||||
repo,
|
||||
}) => {
|
||||
let extras = if !extras.is_empty() {
|
||||
extras
|
||||
} else if no_default_extra {
|
||||
vec![]
|
||||
} else {
|
||||
vec![install::DEFAULT_EXTRA.to_string()]
|
||||
};
|
||||
let config = InstallConfig {
|
||||
repo,
|
||||
git_ref: r#ref,
|
||||
extras,
|
||||
};
|
||||
install::install(&config)
|
||||
}
|
||||
Some(Command::Update) => install::update(),
|
||||
Some(Command::Check { quiet }) => update_check::check(quiet),
|
||||
Some(Command::Refresh) => update_check::refresh(),
|
||||
Some(Command::ShellHook) => {
|
||||
shellhook::print();
|
||||
Ok(())
|
||||
}
|
||||
None => run_supervisor(args.run),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default path: the pod supervisor for the current checkout. This is the only
|
||||
/// path that requires an Omnigent checkout.
|
||||
#[tokio::main]
|
||||
async fn run_supervisor(args: RunArgs) -> Result<()> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
let repo_root = paths::find_repo_root(&cwd)?;
|
||||
let pod_dir = match &args.pod_dir {
|
||||
Some(p) => p.clone(),
|
||||
None => paths::default_pod_dir(&repo_root)?,
|
||||
};
|
||||
|
||||
if args.clean {
|
||||
pod::clean(&pod_dir)?;
|
||||
}
|
||||
std::fs::create_dir_all(&pod_dir)?;
|
||||
|
||||
// Only one omnidev per pod — same-checkout runs share this dir and would
|
||||
// otherwise fight over ports and state. Held until the process exits.
|
||||
let _lock = lock::acquire(&pod_dir)?;
|
||||
|
||||
let ports = Ports::resolve(&pod_dir, args.server_port, args.vite_port)?;
|
||||
// LAN origins are keyed to the resolved Vite port, so compute them here
|
||||
// once the port is known. Empty unless `--trust-lan-origins` is set.
|
||||
let trusted_origins = if args.trust_lan_origins {
|
||||
lan::trusted_lan_origins(ports.vite)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let pod = Arc::new(Pod::create(
|
||||
repo_root,
|
||||
pod_dir,
|
||||
ports,
|
||||
args.vite_host,
|
||||
trusted_origins,
|
||||
)?);
|
||||
|
||||
let shared = Shared::new(&pod);
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<Cmd>();
|
||||
|
||||
// File watcher: Python changes -> Reload commands. Keep the debouncer alive
|
||||
// for the whole session.
|
||||
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(
|
||||
pod.clone(),
|
||||
shared.clone(),
|
||||
!args.no_vite,
|
||||
args.trust_lan_origins,
|
||||
);
|
||||
let sup_handle = tokio::spawn(supervisor.run(cmd_rx));
|
||||
|
||||
// Run the TUI (owns the terminal) until the user quits.
|
||||
let app = tui::App::new(pod.clone(), shared.clone(), cmd_tx.clone());
|
||||
let result = app.run().await;
|
||||
|
||||
// Tear down children, then wait for the supervisor to finish shutdown.
|
||||
let _ = cmd_tx.send(Cmd::Shutdown);
|
||||
let _ = sup_handle.await;
|
||||
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Repo-root discovery and per-repo pod-directory resolution.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
/// Walk up from `start` looking for the checkout root.
|
||||
///
|
||||
/// The root is the first ancestor holding a `.jj/` or `.git/` marker — the VCS
|
||||
/// root. We then require `web/` and `omnigent/` to be present so we fail early
|
||||
/// on an unrelated repo rather than mid-spawn.
|
||||
pub fn find_repo_root(start: &Path) -> Result<PathBuf> {
|
||||
let start = start
|
||||
.canonicalize()
|
||||
.with_context(|| format!("resolving start dir {}", start.display()))?;
|
||||
|
||||
let mut cur: Option<&Path> = Some(&start);
|
||||
while let Some(dir) = cur {
|
||||
if dir.join(".jj").is_dir() || dir.join(".git").exists() {
|
||||
let root = dir.to_path_buf();
|
||||
if !root.join("omnigent").is_dir() || !root.join("web").is_dir() {
|
||||
bail!(
|
||||
"found a VCS root at {} but it lacks omnigent/ and web/ — \
|
||||
run omnidev from inside an Omnigent checkout",
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
return Ok(root);
|
||||
}
|
||||
cur = dir.parent();
|
||||
}
|
||||
bail!(
|
||||
"could not find a checkout root above {} (no .jj or .git marker)",
|
||||
start.display()
|
||||
)
|
||||
}
|
||||
|
||||
/// Stable per-repo pod directory: `${XDG_CACHE_HOME:-~/.cache}/omnidev/<slug>-<hash8>/`.
|
||||
///
|
||||
/// The hash of the canonical repo path keeps two worktrees on distinct pods;
|
||||
/// the slug (repo basename) keeps the path human-readable.
|
||||
pub fn default_pod_dir(repo_root: &Path) -> Result<PathBuf> {
|
||||
let cache = cache_home()?;
|
||||
let slug = repo_root
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "repo".to_string());
|
||||
let hash = short_hash(repo_root.to_string_lossy().as_bytes());
|
||||
Ok(cache.join("omnidev").join(format!("{slug}-{hash}")))
|
||||
}
|
||||
|
||||
/// `${XDG_CACHE_HOME:-~/.cache}`.
|
||||
pub fn cache_home() -> Result<PathBuf> {
|
||||
if let Some(x) = std::env::var_os("XDG_CACHE_HOME") {
|
||||
if !x.is_empty() {
|
||||
return Ok(PathBuf::from(x));
|
||||
}
|
||||
}
|
||||
let home = std::env::var_os("HOME").context("HOME is not set")?;
|
||||
Ok(PathBuf::from(home).join(".cache"))
|
||||
}
|
||||
|
||||
/// `${XDG_CONFIG_HOME:-~/.config}`.
|
||||
pub fn config_home() -> Result<PathBuf> {
|
||||
if let Some(x) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||
if !x.is_empty() {
|
||||
return Ok(PathBuf::from(x));
|
||||
}
|
||||
}
|
||||
let home = std::env::var_os("HOME").context("HOME is not set")?;
|
||||
Ok(PathBuf::from(home).join(".config"))
|
||||
}
|
||||
|
||||
/// `~/.config/omnidev/install.toml` — durable record of install intent.
|
||||
pub fn install_config_path() -> Result<PathBuf> {
|
||||
Ok(config_home()?.join("omnidev").join("install.toml"))
|
||||
}
|
||||
|
||||
/// `~/.cache/omnidev/omnigent-check.json` — volatile update-check state.
|
||||
pub fn check_cache_path() -> Result<PathBuf> {
|
||||
Ok(cache_home()?.join("omnidev").join("omnigent-check.json"))
|
||||
}
|
||||
|
||||
/// FNV-1a 64-bit, rendered as 8 hex chars. No external dep needed — we only
|
||||
/// need a stable, collision-unlikely tag for a filesystem path.
|
||||
fn short_hash(bytes: &[u8]) -> String {
|
||||
let mut hash: u64 = 0xcbf29ce484222325;
|
||||
for &b in bytes {
|
||||
hash ^= b as u64;
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
format!("{:08x}", (hash ^ (hash >> 32)) as u32)
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! A `Pod` = one isolated dev instance: its own state dir, ports, and the env
|
||||
//! map injected into every supervised child.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::ports::Ports;
|
||||
|
||||
pub struct Pod {
|
||||
pub repo_root: PathBuf,
|
||||
pub dir: PathBuf,
|
||||
pub ports: Ports,
|
||||
pub vite_host: String,
|
||||
/// LAN origins to trust for device testing (`--trust-lan-origins`); empty
|
||||
/// otherwise. Fed to the server as `OMNIGENT_WS_ALLOWED_ORIGINS`.
|
||||
pub trusted_origins: Vec<String>,
|
||||
}
|
||||
|
||||
impl Pod {
|
||||
/// Create the pod directory tree (idempotent) and return the pod handle.
|
||||
/// Only omnigent's own state is isolated (DB, artifacts, logs, config); the
|
||||
/// pod inherits your real home, credentials, and caches.
|
||||
pub fn create(
|
||||
repo_root: PathBuf,
|
||||
dir: PathBuf,
|
||||
ports: Ports,
|
||||
vite_host: String,
|
||||
trusted_origins: Vec<String>,
|
||||
) -> Result<Pod> {
|
||||
for sub in ["data/omnigent", "artifacts", "logs", "config"] {
|
||||
let p = dir.join(sub);
|
||||
std::fs::create_dir_all(&p)
|
||||
.with_context(|| format!("creating pod dir {}", p.display()))?;
|
||||
}
|
||||
let pod = Pod {
|
||||
repo_root,
|
||||
dir,
|
||||
ports,
|
||||
vite_host,
|
||||
trusted_origins,
|
||||
};
|
||||
// Seed the pod's config from the developer's real one so it works out
|
||||
// of the box (keeps their providers). Best-effort: a copy failure just
|
||||
// starts the pod with an empty config, so warn rather than abort.
|
||||
if let Some(src) = real_config_path() {
|
||||
let dest = pod.config_dir().join("config.yaml");
|
||||
if let Err(e) = seed_config_file(&src, &dest) {
|
||||
eprintln!("omnidev: could not seed pod config: {e:#}");
|
||||
}
|
||||
}
|
||||
Ok(pod)
|
||||
}
|
||||
|
||||
pub fn db_uri(&self) -> String {
|
||||
format!(
|
||||
"sqlite:///{}",
|
||||
self.dir.join("data/omnigent/chat.db").display()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn artifacts_dir(&self) -> PathBuf {
|
||||
self.dir.join("artifacts")
|
||||
}
|
||||
|
||||
/// The pod's isolated config home, exposed to children as
|
||||
/// `OMNIGENT_CONFIG_HOME` so its `config.yaml` is separate from the
|
||||
/// developer's real `~/.omnigent/config.yaml`.
|
||||
pub fn config_dir(&self) -> PathBuf {
|
||||
self.dir.join("config")
|
||||
}
|
||||
|
||||
pub fn server_url(&self) -> String {
|
||||
format!("http://127.0.0.1:{}", self.ports.server)
|
||||
}
|
||||
|
||||
/// Clickable URLs for display. Terminals linkify `localhost` but often not
|
||||
/// a bare `127.0.0.1`. Functional uses (server bind, host `--server`,
|
||||
/// `OMNIGENT_URL`) stay on `127.0.0.1` so we don't accidentally target IPv6
|
||||
/// `localhost` (`::1`), where the server isn't listening.
|
||||
pub fn server_display_url(&self) -> String {
|
||||
format!("http://localhost:{}", self.ports.server)
|
||||
}
|
||||
|
||||
pub fn vite_display_url(&self) -> String {
|
||||
format!("http://localhost:{}", self.ports.vite)
|
||||
}
|
||||
|
||||
pub fn web_dir(&self) -> PathBuf {
|
||||
self.repo_root.join("web")
|
||||
}
|
||||
|
||||
/// Whether `web/` needs `npm install` before Vite can start: either
|
||||
/// `node_modules/` is absent, or the lockfile / `package.json` is newer
|
||||
/// than the installed tree (a dependency was added/changed since the last
|
||||
/// install — the case that makes Vite's dependency scan fail).
|
||||
pub fn needs_npm_install(&self) -> bool {
|
||||
let web = self.web_dir();
|
||||
let modules = web.join("node_modules");
|
||||
if !modules.is_dir() {
|
||||
return true;
|
||||
}
|
||||
let mtime = |p: PathBuf| std::fs::metadata(p).and_then(|m| m.modified()).ok();
|
||||
let Some(installed) = mtime(modules) else {
|
||||
return true;
|
||||
};
|
||||
// Reinstall if either manifest is newer than node_modules.
|
||||
[web.join("package-lock.json"), web.join("package.json")]
|
||||
.into_iter()
|
||||
.filter_map(mtime)
|
||||
.any(|t| t > installed)
|
||||
}
|
||||
|
||||
/// Directory to watch for backend source changes.
|
||||
pub fn omnigent_dir(&self) -> PathBuf {
|
||||
self.repo_root.join("omnigent")
|
||||
}
|
||||
|
||||
pub fn log_file(&self, name: &str) -> PathBuf {
|
||||
self.dir.join("logs").join(format!("{name}.log"))
|
||||
}
|
||||
|
||||
/// The env overrides applied on top of the inherited parent env for every
|
||||
/// child. We isolate omnigent's own state — the DB, data dir, and config
|
||||
/// home — so concurrent pods don't share a database, pidfile, or
|
||||
/// `config.yaml`. The rest (real `HOME`, credentials, uv/npm caches) is
|
||||
/// inherited, since the agents omnigent runs need it. `OMNIGENT_URL` is the
|
||||
/// seam `web/vite.config.ts` reads to point its proxy at this pod's backend;
|
||||
/// `OMNIGENT_CONFIG_HOME` is where the server/host/runner read `config.yaml`.
|
||||
pub fn env(&self) -> Vec<(String, String)> {
|
||||
let d = |p: &str| self.dir.join(p).display().to_string();
|
||||
let mut env = vec![
|
||||
("OMNIGENT_DATA_DIR".into(), d("data/omnigent")),
|
||||
("OMNIGENT_DATABASE_URI".into(), self.db_uri()),
|
||||
("OMNIGENT_URL".into(), self.server_url()),
|
||||
(
|
||||
"OMNIGENT_CONFIG_HOME".into(),
|
||||
self.config_dir().display().to_string(),
|
||||
),
|
||||
];
|
||||
if let Some(allowed) = self.allowed_origins_env() {
|
||||
env.push(("OMNIGENT_WS_ALLOWED_ORIGINS".into(), allowed));
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
/// The `OMNIGENT_WS_ALLOWED_ORIGINS` value to inject, or `None` to leave it
|
||||
/// untouched. Merges the trusted LAN origins onto any value inherited from
|
||||
/// the parent environment (comma-separated, order-preserving, deduped) so a
|
||||
/// developer's own allowlist survives. Returns `None` when there are no LAN
|
||||
/// origins to add — then the parent's value (if any) simply passes through.
|
||||
fn allowed_origins_env(&self) -> Option<String> {
|
||||
if self.trusted_origins.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let inherited = std::env::var("OMNIGENT_WS_ALLOWED_ORIGINS").unwrap_or_default();
|
||||
let mut merged: Vec<String> = Vec::new();
|
||||
let parts = inherited
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.chain(self.trusted_origins.iter().cloned());
|
||||
for part in parts {
|
||||
if !merged.contains(&part) {
|
||||
merged.push(part);
|
||||
}
|
||||
}
|
||||
Some(merged.join(","))
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a pod directory (for `--clean`). No-op if it does not exist.
|
||||
pub fn clean(dir: &Path) -> Result<()> {
|
||||
if dir.exists() {
|
||||
std::fs::remove_dir_all(dir)
|
||||
.with_context(|| format!("removing pod dir {}", dir.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The developer's real omnigent `config.yaml` to seed a fresh pod from.
|
||||
///
|
||||
/// Honors `OMNIGENT_CONFIG_HOME` if the parent env sets it (nested/test
|
||||
/// setups), else `~/.omnigent/config.yaml` via `HOME`. Returns `None` when the
|
||||
/// file does not exist — a fresh pod then starts with an empty config, just
|
||||
/// like a first-run user.
|
||||
fn real_config_path() -> Option<PathBuf> {
|
||||
let home = match std::env::var_os("OMNIGENT_CONFIG_HOME") {
|
||||
Some(h) if !h.is_empty() => PathBuf::from(h),
|
||||
_ => PathBuf::from(std::env::var_os("HOME")?).join(".omnigent"),
|
||||
};
|
||||
let path = home.join("config.yaml");
|
||||
path.exists().then_some(path)
|
||||
}
|
||||
|
||||
/// Copy `src` to `dest`, but only when `dest` does not already exist — a normal
|
||||
/// pod restart must not clobber config the developer edited inside the pod.
|
||||
/// After `--clean` the whole pod dir is gone, so `dest` is absent and this
|
||||
/// re-seeds.
|
||||
fn seed_config_file(src: &Path, dest: &Path) -> Result<()> {
|
||||
if dest.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::copy(src, dest)
|
||||
.with_context(|| format!("seeding {} from {}", dest.display(), src.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// `real_config_path` reads process-global env; serialize the tests that
|
||||
// set it so parallel runs don't observe each other's overrides.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn tempdir() -> PathBuf {
|
||||
let unique = format!(
|
||||
"omnidev-pod-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
let dir = std::env::temp_dir().join(unique);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn make_pod(pod_dir: PathBuf) -> Pod {
|
||||
Pod::create(
|
||||
tempdir(),
|
||||
pod_dir,
|
||||
Ports {
|
||||
server: 19191,
|
||||
vite: 19292,
|
||||
},
|
||||
"127.0.0.1".into(),
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Point `OMNIGENT_CONFIG_HOME` at `home` for the duration of `f`, restoring
|
||||
/// the previous value afterwards. Serialized against other env-touching
|
||||
/// tests via `ENV_LOCK`.
|
||||
fn with_config_home<T>(home: &Path, f: impl FnOnce() -> T) -> T {
|
||||
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let prev = std::env::var_os("OMNIGENT_CONFIG_HOME");
|
||||
std::env::set_var("OMNIGENT_CONFIG_HOME", home);
|
||||
let out = f();
|
||||
match prev {
|
||||
Some(v) => std::env::set_var("OMNIGENT_CONFIG_HOME", v),
|
||||
None => std::env::remove_var("OMNIGENT_CONFIG_HOME"),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_makes_config_dir() {
|
||||
let real = tempdir(); // empty config home -> nothing to seed
|
||||
let pod = with_config_home(&real, || make_pod(tempdir()));
|
||||
assert!(pod.config_dir().is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_includes_config_home() {
|
||||
let real = tempdir();
|
||||
let pod = with_config_home(&real, || make_pod(tempdir()));
|
||||
let env = pod.env();
|
||||
let got = env
|
||||
.iter()
|
||||
.find(|(k, _)| k == "OMNIGENT_CONFIG_HOME")
|
||||
.map(|(_, v)| v.clone());
|
||||
assert_eq!(got, Some(pod.config_dir().display().to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_seeds_pod_config_from_real() {
|
||||
let real = tempdir();
|
||||
std::fs::write(real.join("config.yaml"), "providers:\n seeded: true\n").unwrap();
|
||||
|
||||
let pod = with_config_home(&real, || make_pod(tempdir()));
|
||||
|
||||
let seeded = std::fs::read_to_string(pod.config_dir().join("config.yaml")).unwrap();
|
||||
assert_eq!(seeded, "providers:\n seeded: true\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_skips_seed_when_real_config_absent() {
|
||||
let real = tempdir(); // no config.yaml inside
|
||||
let pod = with_config_home(&real, || make_pod(tempdir()));
|
||||
assert!(!pod.config_dir().join("config.yaml").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_does_not_overwrite_existing() {
|
||||
let dir = tempdir();
|
||||
let src = dir.join("src.yaml");
|
||||
let dest = dir.join("dest.yaml");
|
||||
std::fs::write(&src, "from: real\n").unwrap();
|
||||
std::fs::write(&dest, "edited: in-pod\n").unwrap();
|
||||
|
||||
seed_config_file(&src, &dest).unwrap();
|
||||
|
||||
// Existing pod-local edits survive; the real config does not clobber them.
|
||||
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "edited: in-pod\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_config_path_honors_config_home() {
|
||||
let real = tempdir();
|
||||
std::fs::write(real.join("config.yaml"), "x: 1\n").unwrap();
|
||||
let got = with_config_home(&real, real_config_path);
|
||||
assert_eq!(got, Some(real.join("config.yaml")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_config_path_falls_back_to_home_dot_omnigent() {
|
||||
// With no OMNIGENT_CONFIG_HOME, the real config resolves under
|
||||
// `$HOME/.omnigent/` — the path a normal pod run seeds from.
|
||||
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let prev_cfg = std::env::var_os("OMNIGENT_CONFIG_HOME");
|
||||
let prev_home = std::env::var_os("HOME");
|
||||
|
||||
let home = tempdir();
|
||||
std::fs::create_dir_all(home.join(".omnigent")).unwrap();
|
||||
std::fs::write(home.join(".omnigent/config.yaml"), "y: 2\n").unwrap();
|
||||
|
||||
std::env::remove_var("OMNIGENT_CONFIG_HOME");
|
||||
std::env::set_var("HOME", &home);
|
||||
let got = real_config_path();
|
||||
|
||||
match prev_cfg {
|
||||
Some(v) => std::env::set_var("OMNIGENT_CONFIG_HOME", v),
|
||||
None => std::env::remove_var("OMNIGENT_CONFIG_HOME"),
|
||||
}
|
||||
match prev_home {
|
||||
Some(v) => std::env::set_var("HOME", v),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
|
||||
assert_eq!(got, Some(home.join(".omnigent/config.yaml")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Free-port probing and per-pod persistence.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::net::TcpListener;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const SERVER_PORT_BASE: u16 = 6767;
|
||||
pub const VITE_PORT_BASE: u16 = 5173;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct Ports {
|
||||
pub server: u16,
|
||||
pub vite: u16,
|
||||
}
|
||||
|
||||
impl Ports {
|
||||
/// Resolve the pod's ports: reuse the persisted pair if still available,
|
||||
/// else probe upward from the preferred bases. Explicit overrides (from CLI
|
||||
/// flags) are honored verbatim.
|
||||
///
|
||||
/// A port is "available" only if it both binds right now *and* isn't already
|
||||
/// claimed by another pod. The bind check alone is racy: `resolve()` runs at
|
||||
/// startup, before children spawn, so a peer pod whose server/vite hasn't
|
||||
/// bound yet would leave the base port looking free and two pods would pick
|
||||
/// it. We read sibling pods' persisted `pod.toml` to skip ports they've
|
||||
/// already claimed, which is timing-independent.
|
||||
pub fn resolve(
|
||||
pod_dir: &Path,
|
||||
server_override: Option<u16>,
|
||||
vite_override: Option<u16>,
|
||||
) -> Result<Ports> {
|
||||
let persisted = load(pod_dir);
|
||||
let mut taken = sibling_claims(pod_dir);
|
||||
|
||||
let server = match server_override {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let reuse = persisted
|
||||
.map(|p| p.server)
|
||||
.filter(|&p| available(p, &taken));
|
||||
reuse
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| probe_from(SERVER_PORT_BASE, &taken))?
|
||||
}
|
||||
};
|
||||
// The server port is now spoken for — don't hand the same number to vite.
|
||||
taken.insert(server);
|
||||
|
||||
let vite = match vite_override {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let reuse = persisted.map(|p| p.vite).filter(|&p| available(p, &taken));
|
||||
reuse
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| probe_from(VITE_PORT_BASE, &taken))?
|
||||
}
|
||||
};
|
||||
|
||||
let ports = Ports { server, vite };
|
||||
save(pod_dir, &ports)?;
|
||||
Ok(ports)
|
||||
}
|
||||
}
|
||||
|
||||
/// A port is usable if it isn't already claimed by a sibling pod and binds now.
|
||||
fn available(port: u16, taken: &HashSet<u16>) -> bool {
|
||||
!taken.contains(&port) && is_free(port)
|
||||
}
|
||||
|
||||
/// True if the port can be bound on loopback right now.
|
||||
fn is_free(port: u16) -> bool {
|
||||
TcpListener::bind(("127.0.0.1", port)).is_ok()
|
||||
}
|
||||
|
||||
/// First available port at or above `base`, skipping sibling-claimed ports.
|
||||
fn probe_from(base: u16, taken: &HashSet<u16>) -> Result<u16> {
|
||||
for port in base..=u16::MAX {
|
||||
if available(port, taken) {
|
||||
return Ok(port);
|
||||
}
|
||||
}
|
||||
anyhow::bail!("no free port at or above {base}")
|
||||
}
|
||||
|
||||
/// Ports claimed in other pods' `pod.toml` under the shared omnidev cache root.
|
||||
/// Best-effort: unreadable/oddly-nested pod dirs just contribute nothing.
|
||||
fn sibling_claims(pod_dir: &Path) -> HashSet<u16> {
|
||||
let mut claimed = HashSet::new();
|
||||
let Some(root) = pod_dir.parent() else {
|
||||
return claimed;
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(root) else {
|
||||
return claimed;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let dir = entry.path();
|
||||
if dir == pod_dir || !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if let Some(p) = load(&dir) {
|
||||
claimed.insert(p.server);
|
||||
claimed.insert(p.vite);
|
||||
}
|
||||
}
|
||||
claimed
|
||||
}
|
||||
|
||||
fn persist_path(pod_dir: &Path) -> std::path::PathBuf {
|
||||
pod_dir.join("pod.toml")
|
||||
}
|
||||
|
||||
fn load(pod_dir: &Path) -> Option<Ports> {
|
||||
let text = std::fs::read_to_string(persist_path(pod_dir)).ok()?;
|
||||
toml::from_str(&text).ok()
|
||||
}
|
||||
|
||||
fn save(pod_dir: &Path, ports: &Ports) -> Result<()> {
|
||||
let text = toml::to_string(ports).context("serializing pod.toml")?;
|
||||
std::fs::write(persist_path(pod_dir), text).context("writing pod.toml")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Concrete command specs for the three supervised processes.
|
||||
|
||||
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()`, so it is not duplicated here.
|
||||
pub struct ProcSpec {
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub cwd: PathBuf,
|
||||
}
|
||||
|
||||
impl ProcSpec {
|
||||
/// `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(),
|
||||
"server".into(),
|
||||
"--host".into(),
|
||||
"127.0.0.1".into(),
|
||||
"--port".into(),
|
||||
pod.ports.server.to_string(),
|
||||
"--database-uri".into(),
|
||||
pod.db_uri(),
|
||||
"--artifact-location".into(),
|
||||
pod.artifacts_dir().display().to_string(),
|
||||
],
|
||||
cwd: pod.repo_root.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `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(),
|
||||
"host".into(),
|
||||
"--server".into(),
|
||||
pod.server_url(),
|
||||
],
|
||||
cwd: pod.repo_root.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `npm install`, from `web/`. Run before Vite when deps are missing or
|
||||
/// stale so Vite's dependency scan doesn't fail on an unresolved import.
|
||||
///
|
||||
/// `--loglevel http` makes npm emit a line per package fetch even when its
|
||||
/// stdout is piped (its progress bar is TTY-only), so the pane streams real
|
||||
/// progress. `--no-fund --no-audit` trims the trailing noise.
|
||||
pub fn npm_install(pod: &Pod) -> ProcSpec {
|
||||
ProcSpec {
|
||||
program: "npm".into(),
|
||||
args: vec![
|
||||
"install".into(),
|
||||
"--no-fund".into(),
|
||||
"--no-audit".into(),
|
||||
"--loglevel".into(),
|
||||
"http".into(),
|
||||
],
|
||||
cwd: pod.web_dir(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `npm run dev -- --host <host> --port <p> --strictPort`, from `web/`.
|
||||
/// `OMNIGENT_URL` (in the pod env) points Vite's proxy at this pod's backend.
|
||||
pub fn vite(pod: &Pod) -> ProcSpec {
|
||||
ProcSpec {
|
||||
program: "npm".into(),
|
||||
args: vec![
|
||||
"run".into(),
|
||||
"dev".into(),
|
||||
"--".into(),
|
||||
"--host".into(),
|
||||
pod.vite_host.clone(),
|
||||
"--port".into(),
|
||||
pod.ports.vite.to_string(),
|
||||
"--strictPort".into(),
|
||||
],
|
||||
cwd: pod.web_dir(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ports::Ports;
|
||||
|
||||
#[test]
|
||||
fn vite_uses_configured_bind_host_but_backend_url_stays_loopback() {
|
||||
let repo = tempdir();
|
||||
let pod_dir = tempdir();
|
||||
let pod = Pod::create(
|
||||
repo,
|
||||
pod_dir,
|
||||
Ports {
|
||||
server: 19191,
|
||||
vite: 19292,
|
||||
},
|
||||
"0.0.0.0".into(),
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let vite = ProcSpec::vite(&pod);
|
||||
let host_flag = vite.args.iter().position(|arg| arg == "--host").unwrap();
|
||||
assert_eq!(vite.args[host_flag + 1], "0.0.0.0");
|
||||
assert_eq!(pod.server_url(), "http://127.0.0.1:19191");
|
||||
}
|
||||
|
||||
fn tempdir() -> std::path::PathBuf {
|
||||
let unique = format!(
|
||||
"omnidev-process-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
let dir = std::env::temp_dir().join(unique);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Emit the shell snippet that runs the daily update check.
|
||||
|
||||
/// The snippet to append to `.zshrc`/`.bashrc`
|
||||
/// (`omnidev shell-hook >> ~/.zshrc`). All throttling and prompting live inside
|
||||
/// `omnidev check`, so this stays trivial and shell-agnostic: run once per
|
||||
/// interactive shell, quietly, and never fail the shell if it errors.
|
||||
///
|
||||
/// It self-guards on `command -v omnidev`, so it's meant to be appended to the
|
||||
/// rc (a static no-op when omnidev is absent) rather than run via
|
||||
/// `eval "$(omnidev shell-hook)"`, which would invoke omnidev on every shell
|
||||
/// startup and error when it isn't on PATH.
|
||||
const HOOK: &str = r#"# omnidev: daily omnigent update check
|
||||
if [ -n "${PS1:-}" ] && command -v omnidev >/dev/null 2>&1; then
|
||||
omnidev check --quiet || true
|
||||
fi"#;
|
||||
|
||||
pub fn print() {
|
||||
println!("{HOOK}");
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Shared state between the supervisor and the TUI.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::logs::LogBuffer;
|
||||
use crate::pod::Pod;
|
||||
|
||||
/// The three supervised processes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProcId {
|
||||
Server,
|
||||
Host,
|
||||
Vite,
|
||||
}
|
||||
|
||||
impl ProcId {
|
||||
pub const ALL: [ProcId; 3] = [ProcId::Server, ProcId::Host, ProcId::Vite];
|
||||
|
||||
pub fn idx(self) -> usize {
|
||||
match self {
|
||||
ProcId::Server => 0,
|
||||
ProcId::Host => 1,
|
||||
ProcId::Vite => 2,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ProcId::Server => "server",
|
||||
ProcId::Host => "host",
|
||||
ProcId::Vite => "vite",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProcStatus {
|
||||
Idle,
|
||||
Starting,
|
||||
Running(u32),
|
||||
Restarting,
|
||||
Crashed,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
impl ProcStatus {
|
||||
pub fn short(&self) -> &'static str {
|
||||
match self {
|
||||
ProcStatus::Idle => "idle",
|
||||
ProcStatus::Starting => "starting",
|
||||
ProcStatus::Running(_) => "running",
|
||||
ProcStatus::Restarting => "restarting",
|
||||
ProcStatus::Crashed => "crashed",
|
||||
ProcStatus::Stopped => "stopped",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State the TUI renders and the supervisor mutates. Guarded by a std mutex;
|
||||
/// locks are held only for the duration of a single push/read.
|
||||
pub struct Shared {
|
||||
pub status: [ProcStatus; 3],
|
||||
pub server: LogBuffer,
|
||||
pub host: LogBuffer,
|
||||
pub vite: LogBuffer,
|
||||
/// Combined, source-tagged view — also receives supervisor events.
|
||||
pub all: LogBuffer,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
pub fn new(pod: &Pod) -> Arc<Mutex<Shared>> {
|
||||
Arc::new(Mutex::new(Shared {
|
||||
status: [ProcStatus::Idle, ProcStatus::Idle, ProcStatus::Idle],
|
||||
server: LogBuffer::new(&pod.log_file("server")),
|
||||
host: LogBuffer::new(&pod.log_file("host")),
|
||||
vite: LogBuffer::new(&pod.log_file("vite")),
|
||||
all: LogBuffer::memory(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn buf_mut(&mut self, id: ProcId) -> &mut LogBuffer {
|
||||
match id {
|
||||
ProcId::Server => &mut self.server,
|
||||
ProcId::Host => &mut self.host,
|
||||
ProcId::Vite => &mut self.vite,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn buf(&self, id: ProcId) -> &LogBuffer {
|
||||
match id {
|
||||
ProcId::Server => &self.server,
|
||||
ProcId::Host => &self.host,
|
||||
ProcId::Vite => &self.vite,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a line from a process: goes to its own pane and the combined view.
|
||||
pub fn log_proc(&mut self, id: ProcId, line: String) {
|
||||
self.all.push(format!("[{}] {}", id.label(), line));
|
||||
self.buf_mut(id).push(line);
|
||||
}
|
||||
|
||||
/// Append a supervisor event (starts, restarts, crashes, reloads).
|
||||
pub fn event(&mut self, line: impl Into<String>) {
|
||||
self.all.push(format!("[omnidev] {}", line.into()));
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, id: ProcId, status: ProcStatus) {
|
||||
self.status[id.idx()] = status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
//! Process supervision: spawn/stop/restart the three children, capture their
|
||||
//! output, and recover from crashes.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{sleep, timeout};
|
||||
|
||||
use crate::pod::Pod;
|
||||
use crate::process::ProcSpec;
|
||||
use crate::state::{ProcId, ProcStatus, Shared};
|
||||
|
||||
/// Commands the TUI (and watcher) send to the supervisor.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Cmd {
|
||||
/// Restart a single process.
|
||||
Restart(ProcId),
|
||||
/// Restart the backend pair: server, then host after `/health`.
|
||||
RestartBackend,
|
||||
/// A backend reload triggered by `n` changed Python files.
|
||||
Reload(usize),
|
||||
/// Tear everything down and stop the supervisor loop.
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
/// Reported by a per-child monitor when the child exits.
|
||||
struct Exit {
|
||||
id: ProcId,
|
||||
generation: u64,
|
||||
status: String,
|
||||
}
|
||||
|
||||
struct Slot {
|
||||
/// Group id (== leader pid) of the currently-running child, if any.
|
||||
pgid: Option<i32>,
|
||||
/// Generation of the current child; bumped on each spawn.
|
||||
generation: u64,
|
||||
/// Consecutive crash count for backoff; reset after a stable run.
|
||||
crashes: u32,
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
impl Default for Slot {
|
||||
fn default() -> Self {
|
||||
Slot {
|
||||
pgid: None,
|
||||
generation: 0,
|
||||
crashes: 0,
|
||||
started: Instant::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Supervisor {
|
||||
pod: Arc<Pod>,
|
||||
shared: Arc<Mutex<Shared>>,
|
||||
env: Vec<(String, String)>,
|
||||
vite_enabled: bool,
|
||||
/// Whether `--trust-lan-origins` was requested, so we can warn if it was
|
||||
/// asked for but no LAN interface turned up any origins to trust.
|
||||
trust_lan_origins: bool,
|
||||
slots: [Slot; 3],
|
||||
/// Generations we stopped on purpose — their exits are not crashes.
|
||||
expected_stops: HashSet<(usize, u64)>,
|
||||
gen_counter: u64,
|
||||
exit_tx: mpsc::UnboundedSender<Exit>,
|
||||
exit_rx: mpsc::UnboundedReceiver<Exit>,
|
||||
}
|
||||
|
||||
impl Supervisor {
|
||||
pub fn new(
|
||||
pod: Arc<Pod>,
|
||||
shared: Arc<Mutex<Shared>>,
|
||||
vite_enabled: bool,
|
||||
trust_lan_origins: bool,
|
||||
) -> Supervisor {
|
||||
let env = pod.env();
|
||||
let (exit_tx, exit_rx) = mpsc::unbounded_channel();
|
||||
Supervisor {
|
||||
pod,
|
||||
shared,
|
||||
env,
|
||||
vite_enabled,
|
||||
trust_lan_origins,
|
||||
slots: Default::default(),
|
||||
expected_stops: HashSet::new(),
|
||||
gen_counter: 0,
|
||||
exit_tx,
|
||||
exit_rx,
|
||||
}
|
||||
}
|
||||
|
||||
fn event(&self, msg: impl Into<String>) {
|
||||
self.shared.lock().unwrap().event(msg);
|
||||
}
|
||||
|
||||
fn set_status(&self, id: ProcId, status: ProcStatus) {
|
||||
self.shared.lock().unwrap().set_status(id, status);
|
||||
}
|
||||
|
||||
/// Main loop: bring everything up, then service commands and child exits
|
||||
/// until `Shutdown`.
|
||||
pub async fn run(mut self, mut cmds: mpsc::UnboundedReceiver<Cmd>) {
|
||||
self.event(format!(
|
||||
"pod {} — server :{} vite :{}",
|
||||
self.pod.dir.display(),
|
||||
self.pod.ports.server,
|
||||
self.pod.ports.vite
|
||||
));
|
||||
if !self.pod.trusted_origins.is_empty() {
|
||||
self.event(format!(
|
||||
"trusting LAN origins for device testing: {}",
|
||||
self.pod.trusted_origins.join(", ")
|
||||
));
|
||||
} else if self.trust_lan_origins {
|
||||
self.event("--trust-lan-origins: no LAN interface found; no extra origins trusted");
|
||||
}
|
||||
|
||||
self.start_backend().await;
|
||||
if self.vite_enabled {
|
||||
self.prepare_vite().await;
|
||||
self.spawn(ProcId::Vite);
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
cmd = cmds.recv() => {
|
||||
match cmd {
|
||||
Some(Cmd::Restart(id)) => self.restart_one(id).await,
|
||||
Some(Cmd::RestartBackend) => {
|
||||
self.event("manual backend restart");
|
||||
self.start_backend_restart().await;
|
||||
}
|
||||
Some(Cmd::Reload(n)) => {
|
||||
self.event(format!("reloading backend ({n} file(s) changed)"));
|
||||
self.start_backend_restart().await;
|
||||
}
|
||||
Some(Cmd::Shutdown) | None => {
|
||||
self.shutdown().await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(exit) = self.exit_rx.recv() => {
|
||||
self.on_exit(exit).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_backend(&mut self) {
|
||||
self.spawn(ProcId::Server);
|
||||
if self.wait_healthy().await {
|
||||
self.spawn(ProcId::Host);
|
||||
} else {
|
||||
self.event("server did not become healthy; host not started");
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart server then host, gated on `/health`. Used by manual restart and
|
||||
/// by the reload path.
|
||||
async fn start_backend_restart(&mut self) {
|
||||
self.stop(ProcId::Host).await;
|
||||
self.stop(ProcId::Server).await;
|
||||
self.set_status(ProcId::Server, ProcStatus::Restarting);
|
||||
self.set_status(ProcId::Host, ProcStatus::Restarting);
|
||||
self.spawn(ProcId::Server);
|
||||
if self.wait_healthy().await {
|
||||
self.spawn(ProcId::Host);
|
||||
} else {
|
||||
self.event("server did not become healthy after restart");
|
||||
}
|
||||
}
|
||||
|
||||
async fn restart_one(&mut self, id: ProcId) {
|
||||
match id {
|
||||
// Restarting the server alone would strand the host on a dead
|
||||
// backend, so treat it as a backend restart.
|
||||
ProcId::Server | ProcId::Host => self.start_backend_restart().await,
|
||||
ProcId::Vite => {
|
||||
if self.vite_enabled {
|
||||
self.event("restarting vite");
|
||||
self.stop(ProcId::Vite).await;
|
||||
self.prepare_vite().await;
|
||||
self.spawn(ProcId::Vite);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spec(&self, id: ProcId) -> ProcSpec {
|
||||
match id {
|
||||
ProcId::Server => ProcSpec::server(&self.pod),
|
||||
ProcId::Host => ProcSpec::host(&self.pod),
|
||||
ProcId::Vite => ProcSpec::vite(&self.pod),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a child in its own process group and wire up output + exit monitor.
|
||||
fn spawn(&mut self, id: ProcId) {
|
||||
let spec = self.spec(id);
|
||||
self.set_status(id, ProcStatus::Starting);
|
||||
|
||||
let mut cmd = Command::new(&spec.program);
|
||||
cmd.args(&spec.args)
|
||||
.current_dir(&spec.cwd)
|
||||
.envs(self.env.iter().cloned())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(false);
|
||||
// Become a session/group leader so we can signal the whole tree
|
||||
// (uvicorn workers, npm -> vite children) via the negative pgid.
|
||||
unsafe {
|
||||
cmd.pre_exec(|| {
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
self.shared
|
||||
.lock()
|
||||
.unwrap()
|
||||
.log_proc(id, format!("failed to spawn {}: {e}", spec.program));
|
||||
self.set_status(id, ProcStatus::Crashed);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let pid = child.id().map(|p| p as i32);
|
||||
self.gen_counter += 1;
|
||||
let generation = self.gen_counter;
|
||||
let slot = &mut self.slots[id.idx()];
|
||||
slot.pgid = pid;
|
||||
slot.generation = generation;
|
||||
slot.started = Instant::now();
|
||||
|
||||
if let Some(p) = pid {
|
||||
self.set_status(id, ProcStatus::Running(p as u32));
|
||||
}
|
||||
|
||||
// Merge stdout + stderr into this process's buffer.
|
||||
if let Some(out) = child.stdout.take() {
|
||||
self.pump(id, out);
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
self.pump(id, err);
|
||||
}
|
||||
|
||||
// Monitor: report the exit so the loop can decide crash vs expected.
|
||||
let tx = self.exit_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let status = match child.wait().await {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(e) => format!("wait error: {e}"),
|
||||
};
|
||||
let _ = tx.send(Exit {
|
||||
id,
|
||||
generation,
|
||||
status,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Run `npm install` to completion before Vite starts, but only when deps
|
||||
/// are missing or stale — otherwise Vite's dependency scan fails on an
|
||||
/// unresolved import (e.g. a dep added to package.json but not installed).
|
||||
/// Output streams into the Vite pane. A failed/absent install is logged but
|
||||
/// non-fatal: we still let Vite try, so a transient npm hiccup doesn't block
|
||||
/// the whole session.
|
||||
async fn prepare_vite(&self) {
|
||||
if !self.pod.needs_npm_install() {
|
||||
return;
|
||||
}
|
||||
self.set_status(ProcId::Vite, ProcStatus::Starting);
|
||||
self.shared.lock().unwrap().log_proc(
|
||||
ProcId::Vite,
|
||||
"web deps missing or stale — running npm install".into(),
|
||||
);
|
||||
|
||||
let spec = ProcSpec::npm_install(&self.pod);
|
||||
let mut cmd = Command::new(&spec.program);
|
||||
cmd.args(&spec.args)
|
||||
.current_dir(&spec.cwd)
|
||||
.envs(self.env.iter().cloned())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
self.shared
|
||||
.lock()
|
||||
.unwrap()
|
||||
.log_proc(ProcId::Vite, format!("failed to run npm install: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(out) = child.stdout.take() {
|
||||
self.pump(ProcId::Vite, out);
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
self.pump(ProcId::Vite, err);
|
||||
}
|
||||
|
||||
// `--loglevel http` streams a line per package fetch, but npm still
|
||||
// goes quiet during the final tree-build/link phase. A slow heartbeat
|
||||
// covers those gaps so the pane never looks frozen.
|
||||
let started = Instant::now();
|
||||
let mut heartbeat = tokio::time::interval(Duration::from_secs(5));
|
||||
heartbeat.tick().await; // the first tick fires immediately; skip it
|
||||
let status = loop {
|
||||
tokio::select! {
|
||||
result = child.wait() => break result,
|
||||
_ = heartbeat.tick() => {
|
||||
let secs = started.elapsed().as_secs();
|
||||
self.shared
|
||||
.lock()
|
||||
.unwrap()
|
||||
.log_proc(ProcId::Vite, format!("… npm install running ({secs}s)"));
|
||||
}
|
||||
}
|
||||
};
|
||||
match status {
|
||||
Ok(s) if s.success() => self.event(format!(
|
||||
"npm install complete ({}s)",
|
||||
started.elapsed().as_secs()
|
||||
)),
|
||||
Ok(s) => self.event(format!("npm install exited {s} — starting Vite anyway")),
|
||||
Err(e) => self.event(format!("npm install wait error: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a task that streams one pipe into the shared buffer, line by line.
|
||||
fn pump<R>(&self, id: ProcId, reader: R)
|
||||
where
|
||||
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
let shared = self.shared.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
shared.lock().unwrap().log_proc(id, line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// SIGTERM the process group, wait briefly, then SIGKILL. Marks the current
|
||||
/// generation as an expected stop so its exit is not counted as a crash.
|
||||
async fn stop(&mut self, id: ProcId) {
|
||||
let (pgid, generation) = {
|
||||
let slot = &self.slots[id.idx()];
|
||||
(slot.pgid, slot.generation)
|
||||
};
|
||||
let Some(pgid) = pgid else {
|
||||
self.set_status(id, ProcStatus::Stopped);
|
||||
return;
|
||||
};
|
||||
self.expected_stops.insert((id.idx(), generation));
|
||||
|
||||
unsafe {
|
||||
libc::kill(-pgid, libc::SIGTERM);
|
||||
}
|
||||
// Give the tree up to ~5s to exit on SIGTERM.
|
||||
for _ in 0..50 {
|
||||
if unsafe { libc::kill(-pgid, 0) } != 0 {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
if unsafe { libc::kill(-pgid, 0) } == 0 {
|
||||
unsafe {
|
||||
libc::kill(-pgid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
self.slots[id.idx()].pgid = None;
|
||||
self.set_status(id, ProcStatus::Stopped);
|
||||
}
|
||||
|
||||
/// Handle a child exit: distinguish an expected stop from a crash and
|
||||
/// schedule a backoff restart for crashes.
|
||||
async fn on_exit(&mut self, exit: Exit) {
|
||||
let key = (exit.id.idx(), exit.generation);
|
||||
if self.expected_stops.remove(&key) {
|
||||
return; // we stopped it on purpose
|
||||
}
|
||||
// Ignore exits from a generation we already replaced.
|
||||
if self.slots[exit.id.idx()].generation != exit.generation {
|
||||
return;
|
||||
}
|
||||
|
||||
self.slots[exit.id.idx()].pgid = None;
|
||||
self.set_status(exit.id, ProcStatus::Crashed);
|
||||
self.event(format!(
|
||||
"{} exited unexpectedly ({})",
|
||||
exit.id.label(),
|
||||
exit.status
|
||||
));
|
||||
|
||||
// Reset the crash counter if the process had been stable for a while.
|
||||
let crashes = {
|
||||
let slot = &mut self.slots[exit.id.idx()];
|
||||
if slot.started.elapsed() > Duration::from_secs(20) {
|
||||
slot.crashes = 0;
|
||||
}
|
||||
slot.crashes += 1;
|
||||
slot.crashes
|
||||
};
|
||||
let backoff = backoff_secs(crashes);
|
||||
self.event(format!(
|
||||
"restarting {} in {backoff}s (attempt {crashes})",
|
||||
exit.id.label(),
|
||||
));
|
||||
sleep(Duration::from_secs(backoff)).await;
|
||||
|
||||
// A server crash takes the host with it — restart the pair.
|
||||
match exit.id {
|
||||
ProcId::Server => self.start_backend_restart().await,
|
||||
ProcId::Host => {
|
||||
if self.wait_healthy().await {
|
||||
self.spawn(ProcId::Host);
|
||||
} else {
|
||||
self.start_backend_restart().await;
|
||||
}
|
||||
}
|
||||
ProcId::Vite => {
|
||||
if self.vite_enabled {
|
||||
self.spawn(ProcId::Vite);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll the server's `/health` until it returns 200 (up to ~30s).
|
||||
async fn wait_healthy(&self) -> bool {
|
||||
let addr = format!("127.0.0.1:{}", self.pod.ports.server);
|
||||
for _ in 0..120 {
|
||||
if health_ok(&addr).await {
|
||||
return true;
|
||||
}
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn shutdown(&mut self) {
|
||||
self.event("shutting down");
|
||||
self.stop(ProcId::Host).await;
|
||||
self.stop(ProcId::Vite).await;
|
||||
self.stop(ProcId::Server).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn backoff_secs(attempt: u32) -> u64 {
|
||||
// 0.5s effectively rounds to 1s here; cap at 30s.
|
||||
match attempt {
|
||||
0 | 1 => 1,
|
||||
2 => 2,
|
||||
3 => 4,
|
||||
4 => 8,
|
||||
5 => 16,
|
||||
_ => 30,
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal HTTP/1.0 `GET /health` returning true on a `200` status line. Avoids
|
||||
/// pulling an HTTP client dependency just for a readiness probe.
|
||||
async fn health_ok(addr: &str) -> bool {
|
||||
let Ok(Ok(mut stream)) = timeout(Duration::from_secs(1), TcpStream::connect(addr)).await else {
|
||||
return false;
|
||||
};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let req = format!("GET /health HTTP/1.0\r\nHost: {addr}\r\n\r\n");
|
||||
if stream.write_all(req.as_bytes()).await.is_err() {
|
||||
return false;
|
||||
}
|
||||
let mut buf = [0u8; 128];
|
||||
let Ok(Ok(n)) = timeout(Duration::from_secs(1), stream.read(&mut buf)).await else {
|
||||
return false;
|
||||
};
|
||||
let head = String::from_utf8_lossy(&buf[..n]);
|
||||
head.starts_with("HTTP/1.") && head.contains(" 200")
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Terminal UI: renders pod status + per-process log panes and turns key
|
||||
//! presses into supervisor commands.
|
||||
|
||||
mod render;
|
||||
|
||||
use std::io::{self, Stdout};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::pod::Pod;
|
||||
use crate::state::{ProcId, Shared};
|
||||
use crate::supervisor::Cmd;
|
||||
|
||||
/// Which log channel is focused. `All` is the combined, source-tagged view.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum View {
|
||||
Server,
|
||||
Host,
|
||||
Vite,
|
||||
All,
|
||||
}
|
||||
|
||||
impl View {
|
||||
fn proc(self) -> Option<ProcId> {
|
||||
match self {
|
||||
View::Server => Some(ProcId::Server),
|
||||
View::Host => Some(ProcId::Host),
|
||||
View::Vite => Some(ProcId::Vite),
|
||||
View::All => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pod: Arc<Pod>,
|
||||
shared: Arc<Mutex<Shared>>,
|
||||
cmds: mpsc::UnboundedSender<Cmd>,
|
||||
view: View,
|
||||
/// Lines scrolled up from the bottom; 0 == pinned to tail.
|
||||
scroll_back: usize,
|
||||
follow: bool,
|
||||
should_quit: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(pod: Arc<Pod>, shared: Arc<Mutex<Shared>>, cmds: mpsc::UnboundedSender<Cmd>) -> App {
|
||||
App {
|
||||
pod,
|
||||
shared,
|
||||
cmds,
|
||||
view: View::All,
|
||||
scroll_back: 0,
|
||||
follow: true,
|
||||
should_quit: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the render + input loop until the user quits. On return, the caller
|
||||
/// sends `Shutdown` and the terminal is already restored.
|
||||
pub async fn run(mut self) -> Result<()> {
|
||||
let mut terminal = setup_terminal()?;
|
||||
let mut input = spawn_input();
|
||||
let mut tick = tokio::time::interval(Duration::from_millis(80));
|
||||
|
||||
let result = loop {
|
||||
if let Err(e) = terminal.draw(|f| render::draw(f, &self)) {
|
||||
break Err(e.into());
|
||||
}
|
||||
if self.should_quit {
|
||||
break Ok(());
|
||||
}
|
||||
tokio::select! {
|
||||
_ = tick.tick() => {}
|
||||
key = input.recv() => {
|
||||
match key {
|
||||
Some(key) => self.on_key(key),
|
||||
None => break Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
restore_terminal(&mut terminal);
|
||||
result
|
||||
}
|
||||
|
||||
fn on_key(&mut self, key: KeyEvent) {
|
||||
if key.kind != KeyEventKind::Press {
|
||||
return;
|
||||
}
|
||||
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),
|
||||
(KeyCode::Char('2'), _) => self.set_view(View::Host),
|
||||
(KeyCode::Char('3'), _) => self.set_view(View::Vite),
|
||||
(KeyCode::Char('0'), _) => self.set_view(View::All),
|
||||
(KeyCode::Tab, _) => self.cycle_view(),
|
||||
|
||||
(KeyCode::Up, _) => self.scroll(1),
|
||||
(KeyCode::Down, _) => self.scroll_down(1),
|
||||
(KeyCode::PageUp, _) => self.scroll(page),
|
||||
(KeyCode::PageDown, _) => self.scroll_down(page),
|
||||
|
||||
(KeyCode::Char('f'), _) => {
|
||||
self.follow = !self.follow;
|
||||
if self.follow {
|
||||
self.scroll_back = 0;
|
||||
}
|
||||
}
|
||||
(KeyCode::Char('r'), _) => {
|
||||
if let Some(id) = self.view.proc() {
|
||||
let _ = self.cmds.send(Cmd::Restart(id));
|
||||
} else {
|
||||
let _ = self.cmds.send(Cmd::RestartBackend);
|
||||
}
|
||||
}
|
||||
(KeyCode::Char('R'), _) => {
|
||||
let _ = self.cmds.send(Cmd::RestartBackend);
|
||||
}
|
||||
(KeyCode::Char('c'), _) => self.clear_current(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_view(&mut self, v: View) {
|
||||
self.view = v;
|
||||
self.scroll_back = 0;
|
||||
}
|
||||
|
||||
fn cycle_view(&mut self) {
|
||||
self.view = match self.view {
|
||||
View::All => View::Server,
|
||||
View::Server => View::Host,
|
||||
View::Host => View::Vite,
|
||||
View::Vite => View::All,
|
||||
};
|
||||
self.scroll_back = 0;
|
||||
}
|
||||
|
||||
fn scroll(&mut self, n: usize) {
|
||||
// Scrolling up detaches from the tail.
|
||||
self.follow = false;
|
||||
self.scroll_back = self.scroll_back.saturating_add(n);
|
||||
}
|
||||
|
||||
fn scroll_down(&mut self, n: usize) {
|
||||
self.scroll_back = self.scroll_back.saturating_sub(n);
|
||||
if self.scroll_back == 0 {
|
||||
self.follow = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_current(&mut self) {
|
||||
let mut s = self.shared.lock().unwrap();
|
||||
match self.view {
|
||||
View::Server => s.server.clear(),
|
||||
View::Host => s.host.clear(),
|
||||
View::Vite => s.vite.clear(),
|
||||
View::All => s.all.clear(),
|
||||
}
|
||||
self.scroll_back = 0;
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
View::Server => s.buf(ProcId::Server).iter().count(),
|
||||
View::Host => s.buf(ProcId::Host).iter().count(),
|
||||
View::Vite => s.buf(ProcId::Vite).iter().count(),
|
||||
View::All => s.all.iter().count(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
Ok(Terminal::new(CrosstermBackend::new(stdout))?)
|
||||
}
|
||||
|
||||
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) {
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
|
||||
let _ = terminal.show_cursor();
|
||||
}
|
||||
|
||||
/// Read crossterm key events on a dedicated thread and forward them; the async
|
||||
/// loop selects on this alongside the render tick.
|
||||
fn spawn_input() -> mpsc::UnboundedReceiver<KeyEvent> {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
std::thread::spawn(move || loop {
|
||||
if event::poll(Duration::from_millis(200)).unwrap_or(false) {
|
||||
if let Ok(Event::Key(key)) = event::read() {
|
||||
if tx.send(key).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Frame rendering. Minimal chrome: no boxes — regions are separated by a
|
||||
//! light neutral background bar instead. The header and footer share the
|
||||
//! "chrome" bar; the log body sits on the terminal's default background so
|
||||
//! ANSI log colors render naturally on either a light or dark theme.
|
||||
|
||||
use ansi_to_tui::IntoText;
|
||||
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Paragraph, Tabs};
|
||||
use ratatui::Frame;
|
||||
|
||||
use super::{App, View};
|
||||
use crate::state::{ProcId, ProcStatus};
|
||||
|
||||
// Palette calibrated (Solarized accents) to stay legible on both light and
|
||||
// dark terminals. The chrome bars use a light neutral background with dark
|
||||
// text; the log body keeps the terminal default background so ANSI log colors
|
||||
// render naturally on either theme. Accent hues are mid-tone so they read on
|
||||
// the light bar and on both a black and a white body background.
|
||||
const CHROME_BG: Color = Color::Rgb(238, 232, 213); // light neutral bar
|
||||
const CHROME_FG: Color = Color::Rgb(60, 70, 72); // dark text on the bar
|
||||
const MUTED: Color = Color::Rgb(120, 132, 133); // de-emphasized labels
|
||||
|
||||
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 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)
|
||||
|
||||
/// Style for the header/footer chrome bars.
|
||||
fn chrome() -> Style {
|
||||
Style::default().bg(CHROME_BG).fg(CHROME_FG)
|
||||
}
|
||||
|
||||
pub fn draw(f: &mut Frame, app: &App) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // pod path
|
||||
Constraint::Length(1), // urls
|
||||
Constraint::Length(1), // status chips
|
||||
Constraint::Length(1), // tabs + scroll status
|
||||
Constraint::Min(1), // body
|
||||
Constraint::Length(1), // footer
|
||||
])
|
||||
.split(f.area());
|
||||
|
||||
draw_pod(f, app, chunks[0]);
|
||||
draw_urls(f, app, chunks[1]);
|
||||
draw_chips(f, app, chunks[2]);
|
||||
draw_tabs_row(f, app, chunks[3]);
|
||||
draw_body(f, app, chunks[4]);
|
||||
draw_footer(f, chunks[5]);
|
||||
}
|
||||
|
||||
fn draw_pod(f: &mut Frame, app: &App, area: Rect) {
|
||||
let line = Line::from(vec![
|
||||
Span::styled(" pod ", Style::default().fg(MUTED)),
|
||||
Span::raw(app.pod.dir.display().to_string()),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(line).style(chrome()), area);
|
||||
}
|
||||
|
||||
fn draw_urls(f: &mut Frame, app: &App, area: Rect) {
|
||||
let line = Line::from(vec![
|
||||
Span::styled(" server ", Style::default().fg(MUTED)),
|
||||
Span::styled(
|
||||
app.pod.server_display_url(),
|
||||
Style::default().fg(proc_color(ProcId::Server)),
|
||||
),
|
||||
Span::styled(" ui ", Style::default().fg(MUTED)),
|
||||
Span::styled(
|
||||
app.pod.vite_display_url(),
|
||||
Style::default().fg(proc_color(ProcId::Vite)),
|
||||
),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(line).style(chrome()), area);
|
||||
}
|
||||
|
||||
fn draw_chips(f: &mut Frame, app: &App, area: Rect) {
|
||||
let status = app.shared.lock().unwrap().status.clone();
|
||||
let mut chips: Vec<Span> = vec![Span::raw(" ")];
|
||||
for id in ProcId::ALL {
|
||||
let st = &status[id.idx()];
|
||||
chips.push(Span::styled(
|
||||
id.label(),
|
||||
Style::default()
|
||||
.fg(proc_color(id))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
chips.push(Span::raw(" "));
|
||||
chips.push(Span::styled(
|
||||
st.short(),
|
||||
Style::default().fg(status_color(st)),
|
||||
));
|
||||
chips.push(Span::raw(" "));
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(chips)).style(chrome()), area);
|
||||
}
|
||||
|
||||
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(24)])
|
||||
.split(area);
|
||||
|
||||
let entries = [
|
||||
("server", View::Server, Some(ProcId::Server)),
|
||||
("host", View::Host, Some(ProcId::Host)),
|
||||
("vite", View::Vite, Some(ProcId::Vite)),
|
||||
("all", View::All, None),
|
||||
];
|
||||
let selected = entries
|
||||
.iter()
|
||||
.position(|(_, v, _)| *v == app.view)
|
||||
.unwrap_or(3);
|
||||
let titles: Vec<Line> = entries
|
||||
.iter()
|
||||
.map(|(name, _, id)| {
|
||||
let color = id.map(proc_color).unwrap_or(CHROME_FG);
|
||||
Line::from(Span::styled(*name, Style::default().fg(color)))
|
||||
})
|
||||
.collect();
|
||||
let tabs = Tabs::new(titles)
|
||||
.select(selected)
|
||||
.style(chrome())
|
||||
.divider(Span::styled("·", Style::default().fg(MUTED)))
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD));
|
||||
f.render_widget(tabs, cols[0]);
|
||||
|
||||
let total = app.line_count();
|
||||
let status = if app.follow {
|
||||
format!("{total} ln · follow ")
|
||||
} else {
|
||||
format!("{total} ln · ↑{} ", app.scroll_back)
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(status, Style::default().fg(MUTED))))
|
||||
.alignment(Alignment::Right)
|
||||
.style(chrome()),
|
||||
cols[1],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_body(f: &mut Frame, app: &App, area: Rect) {
|
||||
let all_view = app.view == View::All;
|
||||
let shared = app.shared.lock().unwrap();
|
||||
let lines: Vec<String> = match app.view {
|
||||
View::Server => shared.buf(ProcId::Server).iter().cloned().collect(),
|
||||
View::Host => shared.buf(ProcId::Host).iter().cloned().collect(),
|
||||
View::Vite => shared.buf(ProcId::Vite).iter().cloned().collect(),
|
||||
View::All => shared.all.iter().cloned().collect(),
|
||||
};
|
||||
drop(shared);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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}]"),
|
||||
Style::default()
|
||||
.fg(label_color(label))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)];
|
||||
spans.extend(ansi_spans(body));
|
||||
return Line::from(spans);
|
||||
}
|
||||
}
|
||||
}
|
||||
Line::from(ansi_spans(raw))
|
||||
}
|
||||
|
||||
/// Parse a single line of possibly-ANSI text into owned spans, falling back to
|
||||
/// the raw string if it doesn't parse.
|
||||
fn ansi_spans(s: &str) -> Vec<Span<'static>> {
|
||||
match s.into_text() {
|
||||
Ok(text) => text
|
||||
.lines
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|l| l.spans)
|
||||
.unwrap_or_default(),
|
||||
Err(_) => vec![Span::raw(s.to_string())],
|
||||
}
|
||||
}
|
||||
|
||||
fn proc_color(id: ProcId) -> Color {
|
||||
match id {
|
||||
ProcId::Server => SERVER,
|
||||
ProcId::Host => HOST,
|
||||
ProcId::Vite => VITE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Color for a `[label]` prefix in the combined view — the three services plus
|
||||
/// the synthetic "omnidev" supervisor channel.
|
||||
fn label_color(label: &str) -> Color {
|
||||
match label {
|
||||
"server" => proc_color(ProcId::Server),
|
||||
"host" => proc_color(ProcId::Host),
|
||||
"vite" => proc_color(ProcId::Vite),
|
||||
"omnidev" => EVENT,
|
||||
_ => MUTED,
|
||||
}
|
||||
}
|
||||
|
||||
fn status_color(st: &ProcStatus) -> Color {
|
||||
match st {
|
||||
ProcStatus::Running(_) => OK,
|
||||
ProcStatus::Starting | ProcStatus::Restarting => WARN,
|
||||
ProcStatus::Crashed => ERR,
|
||||
ProcStatus::Stopped => VITE,
|
||||
ProcStatus::Idle => MUTED,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Daily update check for a git-installed omnigent.
|
||||
//!
|
||||
//! Fills a real gap: omnigent's own update notice only works for PyPI-wheel
|
||||
//! installs and bails on VCS installs. The hot path (`check`) never blocks on
|
||||
//! the network — it reads a cache and spawns a detached `refresh` when stale.
|
||||
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::install::{self, InstallConfig};
|
||||
use crate::paths;
|
||||
|
||||
const STALE_SECS: u64 = 24 * 60 * 60;
|
||||
const LS_REMOTE_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
/// Volatile update-check state cached between runs.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CheckCache {
|
||||
#[serde(default)]
|
||||
pub last_checked: u64,
|
||||
#[serde(default)]
|
||||
pub remote_sha: Option<String>,
|
||||
#[serde(default)]
|
||||
pub installed_sha: Option<String>,
|
||||
/// The remote sha we already prompted about, so a declined update isn't
|
||||
/// re-nagged until a newer commit lands.
|
||||
#[serde(default)]
|
||||
pub last_prompted_sha: Option<String>,
|
||||
}
|
||||
|
||||
impl CheckCache {
|
||||
pub fn load() -> CheckCache {
|
||||
let Ok(path) = paths::check_cache_path() else {
|
||||
return CheckCache::default();
|
||||
};
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str(&t).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = paths::check_cache_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(self).context("serializing check cache")?;
|
||||
std::fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the cache indicates an update the user hasn't already declined.
|
||||
/// Pure so it can be unit-tested without touching disk or the network.
|
||||
///
|
||||
/// `installed` is the best-known installed commit (dist-info first, else the
|
||||
/// cached `installed_sha`). An update is available when we have a remote sha
|
||||
/// that differs from what's installed and that we haven't already prompted for.
|
||||
pub fn update_available(cache: &CheckCache, installed: Option<&str>) -> bool {
|
||||
let Some(remote) = cache.remote_sha.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
if Some(remote) == installed {
|
||||
return false;
|
||||
}
|
||||
if cache.last_prompted_sha.as_deref() == Some(remote) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether `last_checked` is older than the staleness window.
|
||||
pub fn is_stale(cache: &CheckCache, now: u64) -> bool {
|
||||
now.saturating_sub(cache.last_checked) > STALE_SECS
|
||||
}
|
||||
|
||||
fn now_epoch() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The remote HEAD sha of `git_ref` in `repo`, via `git ls-remote` (targets the
|
||||
/// remote, so no local checkout is needed). `None` on any failure/timeout.
|
||||
pub fn remote_sha(repo: &str, git_ref: &str) -> Option<String> {
|
||||
// `timeout` isn't portable (absent on macOS by default), so bound the call
|
||||
// with git's own connect timeout and a wait guard instead.
|
||||
let mut child = Command::new("git")
|
||||
.args(["ls-remote", repo, git_ref])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.stdin(Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
|
||||
let deadline = SystemTime::now() + Duration::from_secs(LS_REMOTE_TIMEOUT_SECS);
|
||||
loop {
|
||||
match child.try_wait().ok()? {
|
||||
Some(_) => break,
|
||||
None => {
|
||||
if SystemTime::now() > deadline {
|
||||
let _ = child.kill();
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
}
|
||||
let output = child.wait_with_output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8(output.stdout).ok()?;
|
||||
// First whitespace-delimited token of the first line is the sha.
|
||||
text.lines()
|
||||
.next()
|
||||
.and_then(|l| l.split_whitespace().next())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Record the installed sha into the cache (called after install/update).
|
||||
pub fn set_installed_sha(sha: &str) -> Result<()> {
|
||||
let mut cache = CheckCache::load();
|
||||
cache.installed_sha = Some(sha.to_string());
|
||||
cache.save()
|
||||
}
|
||||
|
||||
/// `refresh` subcommand: hit the network, update `remote_sha` + `last_checked`.
|
||||
/// Invoked detached by `check`, but also runnable directly.
|
||||
pub fn refresh() -> Result<()> {
|
||||
let config = InstallConfig::load()?.unwrap_or_default();
|
||||
let mut cache = CheckCache::load();
|
||||
cache.remote_sha = remote_sha(&config.repo, &config.git_ref);
|
||||
cache.last_checked = now_epoch();
|
||||
cache.save()
|
||||
}
|
||||
|
||||
/// Best-known installed commit: the tool's dist-info first (authoritative),
|
||||
/// else the sha we recorded at install time.
|
||||
fn installed_commit(cache: &CheckCache) -> Option<String> {
|
||||
install::installed_commit().or_else(|| cache.installed_sha.clone())
|
||||
}
|
||||
|
||||
/// `check` subcommand: the fast hook primitive. Never blocks on the network.
|
||||
///
|
||||
/// - Stale cache ⇒ spawn a detached `refresh` and return.
|
||||
/// - An available update ⇒ notice; on a TTY, prompt and update in the
|
||||
/// foreground on yes, else record the decline.
|
||||
/// - `quiet` suppresses the "up to date" path so shell startup stays silent.
|
||||
pub fn check(quiet: bool) -> Result<()> {
|
||||
let cache = CheckCache::load();
|
||||
|
||||
if is_stale(&cache, now_epoch()) {
|
||||
spawn_detached_refresh();
|
||||
// Still evaluate against whatever we already had cached.
|
||||
}
|
||||
|
||||
let installed = installed_commit(&cache);
|
||||
if !update_available(&cache, installed.as_deref()) {
|
||||
if !quiet {
|
||||
println!("omnigent is up to date.");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remote = cache.remote_sha.clone().unwrap_or_default();
|
||||
let short = |s: &str| s.chars().take(8).collect::<String>();
|
||||
let installed_desc = installed
|
||||
.as_deref()
|
||||
.map(short)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
eprintln!(
|
||||
"omnigent update available: {} → {} (git)",
|
||||
installed_desc,
|
||||
short(&remote),
|
||||
);
|
||||
|
||||
// Only prompt on an interactive terminal; scripts/CI just see the notice.
|
||||
if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if prompt_yes_no("Update omnigent now? [y/N] ") {
|
||||
install::update()?;
|
||||
} else {
|
||||
// Don't re-nag for this same commit.
|
||||
let mut cache = CheckCache::load();
|
||||
cache.last_prompted_sha = Some(remote);
|
||||
cache.save()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prompt on the controlling terminal. Reads from `/dev/tty` so it works even
|
||||
/// when the hook's stdin is redirected. Any read failure ⇒ treated as "no".
|
||||
fn prompt_yes_no(prompt: &str) -> bool {
|
||||
use std::io::BufRead;
|
||||
let Ok(tty) = std::fs::OpenOptions::new().read(true).open("/dev/tty") else {
|
||||
return false;
|
||||
};
|
||||
eprint!("{prompt}");
|
||||
let _ = std::io::stderr().flush();
|
||||
let mut line = String::new();
|
||||
if std::io::BufReader::new(tty).read_line(&mut line).is_err() {
|
||||
return false;
|
||||
}
|
||||
matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
|
||||
}
|
||||
|
||||
/// Launch `omnidev refresh` fully detached so shell startup never waits on it.
|
||||
fn spawn_detached_refresh() {
|
||||
let Ok(exe) = std::env::current_exe() else {
|
||||
return;
|
||||
};
|
||||
let _ = Command::new(exe)
|
||||
.arg("refresh")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Watches the backend source tree and asks the supervisor to reload on
|
||||
//! Python changes. Frontend files are deliberately not watched — Vite HMR
|
||||
//! handles those.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use notify::RecursiveMode;
|
||||
use notify_debouncer_full::new_debouncer;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
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.
|
||||
pub fn spawn(
|
||||
omnigent_dir: &Path,
|
||||
cmd_tx: mpsc::UnboundedSender<Cmd>,
|
||||
) -> Result<impl Send + 'static> {
|
||||
// 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,
|
||||
move |result: notify_debouncer_full::DebounceEventResult| {
|
||||
let Ok(events) = result else { return };
|
||||
let mut changed = 0usize;
|
||||
for event in &events {
|
||||
for path in &event.paths {
|
||||
if is_relevant(path) {
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed > 0 {
|
||||
let _ = cmd_tx.send(Cmd::Reload(changed));
|
||||
}
|
||||
},
|
||||
)
|
||||
.context("creating file watcher")?;
|
||||
|
||||
debouncer
|
||||
.watch(omnigent_dir, RecursiveMode::Recursive)
|
||||
.with_context(|| format!("watching {}", omnigent_dir.display()))?;
|
||||
|
||||
Ok(debouncer)
|
||||
}
|
||||
|
||||
fn is_relevant(path: &Path) -> bool {
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("py") {
|
||||
return false;
|
||||
}
|
||||
!path.components().any(|c| c.as_os_str() == "__pycache__")
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Exercises install-management logic without network or a real install:
|
||||
//! spec building, config round-trip, and the update-availability/staleness
|
||||
//! decisions.
|
||||
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
// These modules reference each other via `crate::`, so declare the whole set at
|
||||
// the test crate root. Each test target exercises only part of the included
|
||||
// source, so allow dead code rather than chase per-item warnings.
|
||||
#[allow(dead_code)]
|
||||
#[path = "../src/install.rs"]
|
||||
mod install;
|
||||
#[allow(dead_code)]
|
||||
#[path = "../src/paths.rs"]
|
||||
mod paths;
|
||||
#[allow(dead_code)]
|
||||
#[path = "../src/update_check.rs"]
|
||||
mod update_check;
|
||||
|
||||
use install::InstallConfig;
|
||||
use update_check::{is_stale, update_available, CheckCache};
|
||||
|
||||
/// Tests here mutate process-global `XDG_*` env vars; serialize them.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn lock_env() -> MutexGuard<'static, ()> {
|
||||
ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spec_default_has_databricks_extra_and_main() {
|
||||
let c = InstallConfig::default();
|
||||
assert_eq!(
|
||||
c.spec(),
|
||||
"omnigent[databricks] @ git+https://github.com/omnigent-ai/omnigent.git@main"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spec_no_extras_is_bare_git_url() {
|
||||
let c = InstallConfig {
|
||||
repo: "https://github.com/omnigent-ai/omnigent.git".into(),
|
||||
git_ref: "main".into(),
|
||||
extras: vec![],
|
||||
};
|
||||
assert_eq!(
|
||||
c.spec(),
|
||||
"git+https://github.com/omnigent-ai/omnigent.git@main"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spec_reflects_custom_ref_and_extras() {
|
||||
let c = InstallConfig {
|
||||
repo: "https://example.com/x.git".into(),
|
||||
git_ref: "dev".into(),
|
||||
extras: vec!["databricks".into(), "kubernetes".into()],
|
||||
};
|
||||
assert_eq!(
|
||||
c.spec(),
|
||||
"omnigent[databricks,kubernetes] @ git+https://example.com/x.git@dev"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_round_trips_through_disk() {
|
||||
let _guard = lock_env();
|
||||
let tmp = tempdir();
|
||||
std::env::set_var("XDG_CONFIG_HOME", &tmp);
|
||||
|
||||
let c = InstallConfig {
|
||||
repo: "https://github.com/omnigent-ai/omnigent.git".into(),
|
||||
git_ref: "main".into(),
|
||||
extras: vec!["databricks".into()],
|
||||
};
|
||||
c.save().unwrap();
|
||||
let loaded = InstallConfig::load().unwrap().expect("config present");
|
||||
assert_eq!(c, loaded);
|
||||
|
||||
std::env::remove_var("XDG_CONFIG_HOME");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_config_loads_as_none() {
|
||||
let _guard = lock_env();
|
||||
let tmp = tempdir();
|
||||
std::env::set_var("XDG_CONFIG_HOME", &tmp);
|
||||
|
||||
assert!(InstallConfig::load().unwrap().is_none());
|
||||
|
||||
std::env::remove_var("XDG_CONFIG_HOME");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_available_logic() {
|
||||
let cache = CheckCache {
|
||||
remote_sha: Some("bbbb".into()),
|
||||
..Default::default()
|
||||
};
|
||||
// Remote differs from installed and wasn't prompted → available.
|
||||
assert!(update_available(&cache, Some("aaaa")));
|
||||
// Installed already matches remote → not available.
|
||||
assert!(!update_available(&cache, Some("bbbb")));
|
||||
// No remote sha known → not available.
|
||||
assert!(!update_available(&CheckCache::default(), Some("aaaa")));
|
||||
|
||||
// Declining a commit (last_prompted_sha == remote) suppresses it.
|
||||
let declined = CheckCache {
|
||||
remote_sha: Some("bbbb".into()),
|
||||
last_prompted_sha: Some("bbbb".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!update_available(&declined, Some("aaaa")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staleness_window() {
|
||||
let now = 1_000_000u64;
|
||||
let day = 24 * 60 * 60;
|
||||
|
||||
let fresh = CheckCache {
|
||||
last_checked: now - 10,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!is_stale(&fresh, now));
|
||||
|
||||
let old = CheckCache {
|
||||
last_checked: now - day - 1,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(is_stale(&old, now));
|
||||
|
||||
// Never checked (last_checked == 0) → stale.
|
||||
assert!(is_stale(&CheckCache::default(), now));
|
||||
}
|
||||
|
||||
/// Minimal unique temp dir without pulling a dev-dependency.
|
||||
fn tempdir() -> std::path::PathBuf {
|
||||
let base = std::env::temp_dir();
|
||||
let unique = format!(
|
||||
"omnidev-mgmt-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
let dir = base.join(unique);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Exercises the non-TUI setup path: repo detection, pod dir tree, ports.
|
||||
|
||||
use std::fs;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
// The crate is a binary, so pull in the modules under test directly. Each test
|
||||
// target uses only part of the included source, so allow dead code.
|
||||
#[allow(dead_code)]
|
||||
#[path = "../src/lock.rs"]
|
||||
mod lock;
|
||||
#[allow(dead_code)]
|
||||
#[path = "../src/paths.rs"]
|
||||
mod paths;
|
||||
#[allow(dead_code)]
|
||||
#[path = "../src/pod.rs"]
|
||||
mod pod;
|
||||
#[allow(dead_code)]
|
||||
#[path = "../src/ports.rs"]
|
||||
mod ports;
|
||||
|
||||
use pod::Pod;
|
||||
use ports::Ports;
|
||||
|
||||
/// A fake checkout (.git + omnigent/ + web/) is recognized as a root, and a
|
||||
/// nested subdir resolves up to it.
|
||||
#[test]
|
||||
fn finds_repo_root_from_subdir() {
|
||||
let tmp = tempdir();
|
||||
fs::create_dir_all(tmp.join(".git")).unwrap();
|
||||
fs::create_dir_all(tmp.join("omnigent/server")).unwrap();
|
||||
fs::create_dir_all(tmp.join("web/src")).unwrap();
|
||||
|
||||
let root = paths::find_repo_root(&tmp.join("omnigent/server")).unwrap();
|
||||
assert_eq!(root, tmp.canonicalize().unwrap());
|
||||
}
|
||||
|
||||
/// A VCS root without omnigent/+web/ is rejected.
|
||||
#[test]
|
||||
fn rejects_non_omnigent_project() {
|
||||
let tmp = tempdir();
|
||||
fs::create_dir_all(tmp.join(".git")).unwrap();
|
||||
assert!(paths::find_repo_root(&tmp).is_err());
|
||||
}
|
||||
|
||||
/// Two different repo paths get distinct pod dirs; the same path is stable.
|
||||
#[test]
|
||||
fn pod_dir_is_per_repo_and_stable() {
|
||||
let a1 = paths::default_pod_dir(std::path::Path::new("/repos/one")).unwrap();
|
||||
let a2 = paths::default_pod_dir(std::path::Path::new("/repos/one")).unwrap();
|
||||
let b = paths::default_pod_dir(std::path::Path::new("/repos/two")).unwrap();
|
||||
assert_eq!(a1, a2);
|
||||
assert_ne!(a1, b);
|
||||
}
|
||||
|
||||
/// npm install is needed when node_modules is missing, and when a manifest is
|
||||
/// newer than it; not needed when node_modules is up to date.
|
||||
#[test]
|
||||
fn needs_npm_install_tracks_manifests() {
|
||||
let repo = tempdir();
|
||||
let web = repo.join("web");
|
||||
fs::create_dir_all(&web).unwrap();
|
||||
fs::write(web.join("package.json"), "{}").unwrap();
|
||||
|
||||
let pod = Pod {
|
||||
repo_root: repo.clone(),
|
||||
dir: repo.join("pod"),
|
||||
ports: Ports {
|
||||
server: 6767,
|
||||
vite: 5173,
|
||||
},
|
||||
vite_host: "127.0.0.1".into(),
|
||||
trusted_origins: Vec::new(),
|
||||
};
|
||||
|
||||
// No node_modules yet → install needed.
|
||||
assert!(pod.needs_npm_install());
|
||||
|
||||
// Fresh node_modules created after the manifest → up to date.
|
||||
fs::create_dir_all(web.join("node_modules")).unwrap();
|
||||
assert!(!pod.needs_npm_install());
|
||||
|
||||
// A manifest touched after node_modules → stale, install needed.
|
||||
// (Sleep briefly so the mtime is observably newer on coarse filesystems.)
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
fs::write(web.join("package-lock.json"), "{}").unwrap();
|
||||
assert!(pod.needs_npm_install());
|
||||
}
|
||||
|
||||
/// Ports probe to bindable values and persist/reuse across calls.
|
||||
#[test]
|
||||
fn ports_resolve_and_persist() {
|
||||
let tmp = tempdir();
|
||||
let p1 = Ports::resolve(&tmp, None, None).unwrap();
|
||||
assert_ne!(p1.server, p1.vite);
|
||||
assert!(tmp.join("pod.toml").is_file());
|
||||
|
||||
// A second resolve reuses the persisted pair (both still free).
|
||||
let p2 = Ports::resolve(&tmp, None, None).unwrap();
|
||||
assert_eq!(p1.server, p2.server);
|
||||
assert_eq!(p1.vite, p2.vite);
|
||||
|
||||
// Explicit overrides win.
|
||||
let p3 = Ports::resolve(&tmp, Some(19191), Some(19292)).unwrap();
|
||||
assert_eq!(p3.server, 19191);
|
||||
assert_eq!(p3.vite, 19292);
|
||||
}
|
||||
|
||||
/// Two sibling pods under the same cache root never collide, even before their
|
||||
/// processes have bound anything — the second reads the first's pod.toml.
|
||||
#[test]
|
||||
fn sibling_pods_get_distinct_ports() {
|
||||
let root = tempdir();
|
||||
let pod_a = root.join("repo-aaaa");
|
||||
let pod_b = root.join("repo-bbbb");
|
||||
fs::create_dir_all(&pod_a).unwrap();
|
||||
fs::create_dir_all(&pod_b).unwrap();
|
||||
|
||||
// Pod A resolves and persists first (no process is ever spawned).
|
||||
let a = Ports::resolve(&pod_a, None, None).unwrap();
|
||||
// Pod B must avoid A's ports purely from A's persisted claim.
|
||||
let b = Ports::resolve(&pod_b, None, None).unwrap();
|
||||
|
||||
assert_ne!(a.server, b.server);
|
||||
assert_ne!(a.vite, b.vite);
|
||||
assert_ne!(a.server, b.vite);
|
||||
assert_ne!(a.vite, b.server);
|
||||
}
|
||||
|
||||
/// A pod admits one holder; a second acquire fails until the first is dropped.
|
||||
#[test]
|
||||
fn pod_lock_is_exclusive() {
|
||||
let pod = tempdir();
|
||||
|
||||
let held = lock::acquire(&pod).expect("first acquire succeeds");
|
||||
assert!(
|
||||
lock::acquire(&pod).is_err(),
|
||||
"second acquire must fail while the first is held"
|
||||
);
|
||||
|
||||
drop(held);
|
||||
lock::acquire(&pod).expect("acquire succeeds again after release");
|
||||
}
|
||||
|
||||
const ALLOWED_ORIGINS_ENV: &str = "OMNIGENT_WS_ALLOWED_ORIGINS";
|
||||
|
||||
/// Tests that read/write the process-global allowlist env var; serialize them.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn lock_env() -> MutexGuard<'static, ()> {
|
||||
ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Run `body` with `OMNIGENT_WS_ALLOWED_ORIGINS` set to `value` (or unset when
|
||||
/// `None`), restoring the prior value afterward so tests don't leak env state.
|
||||
fn with_allowlist_env(value: Option<&str>, body: impl FnOnce()) {
|
||||
let _guard = lock_env();
|
||||
let prev = std::env::var(ALLOWED_ORIGINS_ENV).ok();
|
||||
match value {
|
||||
Some(v) => std::env::set_var(ALLOWED_ORIGINS_ENV, v),
|
||||
None => std::env::remove_var(ALLOWED_ORIGINS_ENV),
|
||||
}
|
||||
body();
|
||||
match prev {
|
||||
Some(v) => std::env::set_var(ALLOWED_ORIGINS_ENV, v),
|
||||
None => std::env::remove_var(ALLOWED_ORIGINS_ENV),
|
||||
}
|
||||
}
|
||||
|
||||
fn pod_with_trusted(trusted: Vec<String>) -> Pod {
|
||||
Pod {
|
||||
repo_root: std::path::PathBuf::from("/repo"),
|
||||
dir: std::path::PathBuf::from("/pod"),
|
||||
ports: Ports {
|
||||
server: 6767,
|
||||
vite: 5173,
|
||||
},
|
||||
vite_host: "0.0.0.0".into(),
|
||||
trusted_origins: trusted,
|
||||
}
|
||||
}
|
||||
|
||||
fn allowlist_from_env(pod: &Pod) -> Option<String> {
|
||||
pod.env()
|
||||
.into_iter()
|
||||
.find(|(k, _)| k == ALLOWED_ORIGINS_ENV)
|
||||
.map(|(_, v)| v)
|
||||
}
|
||||
|
||||
/// With no trusted origins, the pod leaves the allowlist var untouched — even
|
||||
/// when the developer's shell already exports one (it passes through inherited).
|
||||
#[test]
|
||||
fn no_trusted_origins_does_not_set_allowlist() {
|
||||
with_allowlist_env(Some("https://dev.example.com"), || {
|
||||
let pod = pod_with_trusted(Vec::new());
|
||||
assert_eq!(allowlist_from_env(&pod), None);
|
||||
});
|
||||
}
|
||||
|
||||
/// Trusted origins with no inherited value produce exactly those origins.
|
||||
#[test]
|
||||
fn trusted_origins_populate_allowlist() {
|
||||
with_allowlist_env(None, || {
|
||||
let pod = pod_with_trusted(vec!["http://192.168.1.42:5173".into()]);
|
||||
assert_eq!(
|
||||
allowlist_from_env(&pod).as_deref(),
|
||||
Some("http://192.168.1.42:5173")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// A developer's inherited allowlist is preserved and the LAN origins are
|
||||
/// appended (order-preserving, deduped) rather than clobbered.
|
||||
#[test]
|
||||
fn trusted_origins_merge_with_inherited_allowlist() {
|
||||
with_allowlist_env(
|
||||
Some("https://dev.example.com, http://192.168.1.42:5173"),
|
||||
|| {
|
||||
let pod = pod_with_trusted(vec![
|
||||
"http://192.168.1.42:5173".into(), // already inherited → not duplicated
|
||||
"http://10.0.0.9:5173".into(),
|
||||
]);
|
||||
assert_eq!(
|
||||
allowlist_from_env(&pod).as_deref(),
|
||||
Some("https://dev.example.com,http://192.168.1.42:5173,http://10.0.0.9:5173")
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Minimal unique temp dir without pulling a dev-dependency.
|
||||
fn tempdir() -> std::path::PathBuf {
|
||||
let base = std::env::temp_dir();
|
||||
let unique = format!(
|
||||
"omnidev-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
let dir = base.join(unique);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
# Queue + steer design
|
||||
|
||||
Client-side message queue with edit / delete / steer / reorder, for both SDK and
|
||||
native harnesses.
|
||||
|
||||
## 1. Motivation
|
||||
|
||||
Today every message is **POSTed the moment the user hits send** — including
|
||||
follow-ups typed while the agent is still working — and rendered immediately as an
|
||||
optimistic bubble. The runner buffers a mid-turn message behind the active turn
|
||||
and delivers it later, but the UI has already committed it. Problems:
|
||||
|
||||
- **No edit / delete / reorder.** Once POSTed the message is server-owned, so the
|
||||
user can't take back or fix a follow-up they queued in a hurry.
|
||||
- **No queued-vs-sent visibility.** A follow-up sent mid-turn looks identical to a
|
||||
normal send — the user can't tell it's waiting behind the active turn, or when
|
||||
it will be picked up.
|
||||
- **Silent cross-harness inconsistency.** The *same* action — "send a follow-up
|
||||
while the agent is working" — behaves differently per harness (mid-turn steer
|
||||
for live-queue SDKs, next-turn for everyone else) with no signal telling the
|
||||
user which they'll get.
|
||||
|
||||
The redesign fixes all three by holding the message in a **client-side queue
|
||||
before it is POSTed**: the user can edit / delete / reorder while it waits, sees
|
||||
it explicitly as "queued", and controls when it's sent (auto-flush on idle, or
|
||||
steer now).
|
||||
|
||||
## 2. Proposal
|
||||
|
||||
Move the queue **client-side**. The strip becomes a pre-POST draft buffer; a
|
||||
message is only sent to the server when it's flushed or steered.
|
||||
|
||||
```
|
||||
type → client queue "⏱ Queued" (NOT posted) → flush/steer → POST → bubble
|
||||
(strip = "not yet sent, still editable"; bubble = "sent, in flight")
|
||||
```
|
||||
|
||||
### Queue behavior
|
||||
|
||||
- **Show as queued** when the agent is **not idle** (`sessionStatus` busy) — same
|
||||
signal for SDK and native.
|
||||
- **Auto-flush head on idle (FIFO):** when the agent goes idle, send the head of
|
||||
the queue as the next turn. Type-ahead "just works" without any click.
|
||||
- Persist the queue in `localStorage` (keyed by session) so it survives a hard
|
||||
refresh. (Trade-off: no cross-device sync — acceptable for unsent drafts.)
|
||||
|
||||
### Per-message actions
|
||||
|
||||
| Action | Behavior |
|
||||
|--------|----------|
|
||||
| **Edit** | pull the message back into the composer, purely client-side; persists across navigation/refresh |
|
||||
| **Delete** | drop the message from the queue |
|
||||
| **Steer** | POST it now (jump the queue) — deliver mid-turn where the harness supports it |
|
||||
| **Reorder** | client-side drag (grip handle) to reorder the queue within a conversation |
|
||||
|
||||
### Promote-to-bubble rule
|
||||
|
||||
Promote a message from the strip into a normal chat bubble **as soon as it is
|
||||
POSTed** (on flush or steer) — *not* when the agent consumes it. Once it's sent
|
||||
there's no longer anything to edit / delete / steer / reorder, so the strip has
|
||||
no reason to hold it.
|
||||
|
||||
The gap between (a) sent to server and (b) consumed by the agent becomes an
|
||||
**implementation detail** the user need not see — because the strip no longer
|
||||
represents server state, only the still-editable client buffer. This removes the
|
||||
consume-timing dependency entirely.
|
||||
|
||||
### What "steer" means per harness
|
||||
|
||||
Steer always POSTs immediately; how it lands depends on the harness:
|
||||
|
||||
Steer always POSTs immediately (client-side, no runner change); how it lands
|
||||
depends on the harness. The steer button is shown for **all** native sessions —
|
||||
the runner delivers uniformly (POST → buffer → drain → hand to app, all natives'
|
||||
`run_turn` return right after delivery), and the app decides what to do with a
|
||||
message that arrives mid-response:
|
||||
|
||||
| Harness | Steer delivery | Mid-turn? |
|
||||
|---------|----------------|-----------|
|
||||
| claude-sdk / codex-sdk / pi-sdk | runner **live injection** (`_live_response_id` gate) | ✅ deterministic |
|
||||
| cursor-sdk / copilot-sdk | buffer & drain | ❌ next turn |
|
||||
| **codex-native** | explicit **`turn/steer`** RPC when a turn is active | ✅ deterministic *(verified)* |
|
||||
| **claude-native** | `send-keys` into the **live pane**; the TUI folds the paste into the response | ✅ verified (best-effort timing) |
|
||||
| cursor-native / hermes-native | `send-keys` paste into the **live pane** (`supports_enqueue=True`) | ⚠️ app-defined — mechanism confirmed in code, **not yet verified live** |
|
||||
| pi-native | queued to the **resident extension** (`supports_enqueue=True`) | ⚠️ app-defined — mechanism confirmed in code, not yet verified live |
|
||||
| opencode-native | HTTP prompt (`supports_enqueue=True`); the native server has **no live-steer endpoint** → admitted as a new prompt, promoted by the server's own queue at turn end | ❌ next turn (code-confirmed) |
|
||||
| qwen / goose / kimi / kiro / antigravity -native | paste / file / RPC into the app (`supports_enqueue=True`) | ⚠️ app-defined — not yet verified live |
|
||||
|
||||
> **TODO (live verification):** every native harness above reports
|
||||
> `supports_live_message_queue = True` and its delivery mechanism is confirmed
|
||||
> in code (see the enqueue path per harness), but whether the vendor app folds
|
||||
> the steered message in **mid-response** vs. at the **next turn** is confirmed
|
||||
> against a *live* runner only for claude-native + codex-native. Run a live
|
||||
> steer per harness to upgrade the ⚠️ rows. opencode-native is settled: its app
|
||||
> server exposes no live-steer endpoint, so the steered message is always
|
||||
> promoted at the next turn boundary.
|
||||
|
||||
**No runner change is required for native steer** — every native `run_turn`
|
||||
returns right after delivering the input (decoupled from the response), so the
|
||||
drain fires the next message quickly and it reaches the app while the prior
|
||||
response is likely still running; the app does its own steering. Frame the UX
|
||||
honestly: *"send now; the agent folds it into current work if it can"* — which is
|
||||
exactly how native type-ahead already feels. Do **not** promise deterministic
|
||||
mid-turn for the unverified natives.
|
||||
|
||||
**Steer is not interrupt.** In every case above, steer *does not cancel* the
|
||||
running turn — the message is folded in at the agent's next natural breakpoint
|
||||
(after the current tool/step completes), the same feel as steering native Claude
|
||||
by typing while it works. For SDK, `enqueue_session_message` adds the message to
|
||||
the running session's queue; the SDK surfaces it at its next turn-boundary — no
|
||||
teardown. This is distinct from the **Interrupt** button, which really does
|
||||
cancel the turn (`turn.cancel()`).
|
||||
|
||||
### Edges to handle
|
||||
|
||||
| Edge | Rule |
|
||||
|------|------|
|
||||
| POST fails after promote | revert the bubble to the queue (or error-badge it) |
|
||||
| Agent goes idle mid-edit | editing pins the message out of auto-flush until re-committed |
|
||||
| Native mirror-back | consume/mirror still needed as a **reconcile** signal (id-match the optimistic bubble to the real transcript item) so native round-trips don't double-render |
|
||||
|
||||
## 3. Appendix — lifecycle & topology
|
||||
|
||||
### Component topology
|
||||
|
||||
```
|
||||
┌──────────┐ HTTPS+SSE ┌──────────────┐ HTTP ┌──────────┐ HTTP/UNIX socket ┌─────────────────┐
|
||||
│ CLIENT │◄───────────►│ AP SERVER │◄──────►│ RUNNER │◄──────────────────►│ HARNESS SUBPROC │
|
||||
│ (browser)│ │ persist+relay│ │ buffer + │ (1 per conv) │ EXECUTOR=agent │
|
||||
└──────────┘ └──────────────┘ │ schedule │ │ SDK: in-process │
|
||||
└──────────┘ │ native: →app ───┼─► tmux / RPC
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
The agent runs **inside the harness subprocess** (SDK loop) or is **bridged out**
|
||||
of it to a real app (native). It does **not** live in the runner process.
|
||||
|
||||
### Busy/idle signal (drives the queue)
|
||||
|
||||
| Harness | "running" from | "idle" from |
|
||||
|---------|----------------|-------------|
|
||||
| SDK | `response.created` → `_live_response_id` set | `response.completed` / stream-end |
|
||||
| native | `UserPromptSubmit` hook | `Stop` / `StopFailure` hook (relayed by the transcript forwarder) |
|
||||
|
||||
Both surface to the client as the same `sessionStatus` field, seeded from the
|
||||
snapshot on bind (correct after refresh, across tabs).
|
||||
|
||||
### Live-injection gate (SDK steer)
|
||||
|
||||
```python
|
||||
_can_forward = (
|
||||
not _native # native uses paste / turn-steer, not this path
|
||||
and not _awaiting_approval # don't steer a turn parked on a human gate
|
||||
and conversation_id in _live_response_id # a response is actually streaming
|
||||
)
|
||||
```
|
||||
|
||||
### Native decoupling (why paste-steer works)
|
||||
|
||||
Native `run_turn` returns as soon as `send-keys` finishes pasting (not when the
|
||||
agent finishes). `_active_turns` clears immediately, so the buffer drains the
|
||||
next message quickly and it pastes into the still-live pane — the native app then
|
||||
decides to steer it. `_native_pane_status` is the reliable liveness signal for a
|
||||
long autonomous native turn (since `_active_turns` clears early).
|
||||
+203
-103
@@ -6,6 +6,13 @@ available", "is steering possible", "does policy DENY actually block a call" —
|
||||
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, three 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
|
||||
> sections before it describe the design and the decisions behind it.
|
||||
|
||||
## Motivation
|
||||
|
||||
We maintain a capability matrix by hand (the native + SDK support
|
||||
@@ -59,6 +66,13 @@ This constraint is what shapes the coupling decision below. It is *not* a limit
|
||||
on what the bench can probe: the probes are harness-agnostic. It is only a limit
|
||||
on how a harness gets *discovered*.
|
||||
|
||||
> **Update since this was written:** entry-point plugin discovery now exists —
|
||||
> `harness_capabilities()` merges contributions from the
|
||||
> `omnigent.community.harness` entry-point group, and the bench derives
|
||||
> everything from it. So the bench side of option B is realized: a plugin's
|
||||
> harness flows in with no bench edit. The remaining hardcoded seam is *not*
|
||||
> here — it is the server's native-agent seeding (see "Plugin seamlessness").
|
||||
|
||||
## Decision: option B (registry-indexed now, profile-driven from day one)
|
||||
|
||||
Two coupling options were considered:
|
||||
@@ -112,48 +126,42 @@ list" to "discover"; probes, profiles, and reports are untouched.
|
||||
|
||||
## Architecture
|
||||
|
||||
Three layers plus a report step.
|
||||
The implementation has three layers plus reporting:
|
||||
|
||||
```
|
||||
tests/harness_bench/
|
||||
profile.py # BenchProfile: per-harness self-declared facts
|
||||
manifest.py # registry of official BenchProfiles (the spreadsheet as data)
|
||||
verdict.py # Verdict enum, ProbeResult, priority (P0/P1)
|
||||
transports/ # transport drivers keyed by class
|
||||
_base.py # TransportDriver: launch/session/turn against a harness
|
||||
sdk_inproc.py # in-proc HTTP (reuses existing e2e server helpers)
|
||||
tmux_tui.py # (phase 2)
|
||||
app_server.py # (phase 2)
|
||||
http_sse.py # (phase 2)
|
||||
probes/ # one module per dimension
|
||||
_base.py # CapabilityProbe: name, priority, applies_to, declared(), run()
|
||||
basic_turn.py
|
||||
streaming.py
|
||||
tool_calling.py # incl. "connects to Omnigent MCP"
|
||||
interrupt.py
|
||||
policy_deny.py
|
||||
model_override.py
|
||||
... # (phase 2: steering, live_queue, resume_fork, elicitation,
|
||||
# reasoning, images, cost, compaction)
|
||||
bench.py # driver: iterate probes x harnesses -> matrix
|
||||
report.py # render Markdown + JSON, with a DRIFT column
|
||||
test_bench.py # pytest wrapper (parametrized) for CI
|
||||
profile.py # BenchProfile and profile-name resolution
|
||||
manifest.py # official profiles derived from capabilities + e2e metadata
|
||||
verdict.py # verdict vocabulary, priority, and drift reconciliation
|
||||
transport.py # semantic Driver protocol and transport resolution
|
||||
driver.py # sdk-inproc driver + shared TurnResult/usage helpers
|
||||
full_server.py # shared server/runner lifecycle and registration
|
||||
full_server_driver.py # full-server driver and session polling
|
||||
native_tui_driver.py # native vendor CLI + host-daemon/tmux driver
|
||||
session_items.py # shared session-item envelope parsing
|
||||
runtime_env.py # config/credential resolution matching `omni run`
|
||||
probes/ # one module per capability dimension
|
||||
events.py # structured progress events and plain sink
|
||||
richreport.py # optional live Rich matrix
|
||||
bench.py # orchestration, concurrency, and shared-server wiring
|
||||
report.py # terminal, Markdown, and JSON rendering
|
||||
```
|
||||
|
||||
- **Layer 0 — Profile / manifest.** The spreadsheet, as data. Source of truth
|
||||
for the static columns and the *expected* verdicts for behavioral ones.
|
||||
- **Layer 1 — Offline conformance** (no network, always in CI). Harness
|
||||
registers, `create_app()` builds, required routes exist, `Executor` flags are
|
||||
internally consistent, a `BenchProfile` exists. Fast, catches structural
|
||||
regressions.
|
||||
- **Layer 2 — Live probes** (gated on CLI + creds; reuses
|
||||
`skip_if_harness_cli_missing`). Runs the behavioral table against a live
|
||||
server, exactly like the existing e2e tests
|
||||
(`/v1/sessions` + `send_user_message_to_session` +
|
||||
`poll_session_until_terminal` + `final_assistant_text`).
|
||||
- **Report.** `python -m tests.harness_bench --harness codex` prints one
|
||||
harness's matrix; no filter regenerates the whole sheet with a `DRIFT` column
|
||||
diffing declared vs observed.
|
||||
Reusable configuration and runtime primitives live in production modules such
|
||||
as `omnigent.config`, `find_free_port`, and the harness registry rather than
|
||||
being reimplemented under tests.
|
||||
|
||||
- **Layer 0 — Profile / manifest.** Static facts and declared verdicts are
|
||||
derived from `harness_capabilities()` plus the existing e2e harness metadata.
|
||||
- **Layer 1 — Offline conformance.** No network or credentials. It validates
|
||||
registration, profile shape, capability derivation, transport resolution,
|
||||
rendering, and orchestration behavior in normal CI.
|
||||
- **Layer 2 — Live probes.** Drivers execute behavioral probes through the
|
||||
wrap boundary or the real server/runner session API. Missing credentials,
|
||||
vendor binaries, or vendor login produce capability-neutral skips.
|
||||
- **Report.** The CLI renders the declared matrix offline or reconciles live
|
||||
observations into terminal, Markdown, and JSON reports. `DRIFT` produces a
|
||||
non-zero exit status.
|
||||
|
||||
### Build on `HarnessProbe`, don't reinvent it
|
||||
|
||||
@@ -193,20 +201,18 @@ Validated for presence and shape only: `Owner`, `Transport`, `Implementation`,
|
||||
|
||||
| Dimension | How the probe proves it |
|
||||
|---|---|
|
||||
| Basic turn (prereq) | ask model to reply with `<marker>`, assert marker in final text |
|
||||
| Connects to Omnigent MCP | expose an Omnigent tool, ask model to call it, assert `ToolCallRequest` dispatched through the relay |
|
||||
| Streaming | count `TextChunk` events: >1 delta = `deltas`, single blob = `complete-only` |
|
||||
| Model override | launch with a chosen model, assert routing (gateway request / `TurnComplete` usage model); cross-family reject verified via `model_family_mismatch` |
|
||||
| Policy: DENY | set DENY on a tool, ask model to call it, assert the call is blocked + surfaced |
|
||||
| Policy: ASK -> Elicitation | set ASK, assert an elicitation event is emitted upstream (web-surfaceable) |
|
||||
| Interrupt | start a long turn, call `interrupt_session`, assert it stops promptly |
|
||||
| Live queue (concurrent) | `enqueue_session_message` mid-turn, assert accepted (not rejected) |
|
||||
| Tool-boundary steer | inject steering text at a tool boundary, assert the next turn reflects it |
|
||||
| Resume/fork from transcript | run a convo, resume in a fresh session, assert prior context present; fork = branch diverges |
|
||||
| Compaction | assert `CompactionComplete` surfaced when triggered |
|
||||
| Reasoning | reasoning-heavy prompt, assert `ReasoningChunk` emitted |
|
||||
| Images | send an image, assert the model describes it |
|
||||
| Cost tracking | assert `TurnComplete` carries usage / cost |
|
||||
| Basic turn (P0 prerequisite) | complete a marker-echo turn and require assistant text |
|
||||
| Streaming (P0) | count output-text deltas; repeated single-delta output is `PARTIAL` |
|
||||
| Tool calling (P0) | provoke the transport's tool mechanism and require a surfaced call |
|
||||
| Policy DENY (P0) | apply a tool-call deny and require a blocked-call signal |
|
||||
| Policy ALLOW (P1) | attach an explicit allow and require a non-blocked tool output; native hooks expose no positive ALLOW event |
|
||||
| Policy ASK (P1) | apply ask and require an elicitation/approval request |
|
||||
| Model override (P0) | validate the requested harness/model pair and complete a turn |
|
||||
| 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/fork, reasoning, images,
|
||||
and compaction.
|
||||
|
||||
Every behavioral probe also reads the corresponding declared flag and returns
|
||||
`DRIFT` when observed disagrees with declared.
|
||||
@@ -234,27 +240,56 @@ class StreamingProbe(CapabilityProbe):
|
||||
|
||||
## Transport drivers: the real ceiling on "all dimensions"
|
||||
|
||||
Behavioral probes run through a **transport driver** keyed by transport class
|
||||
(SDK in-proc HTTP, tmux TUI, app-server, HTTP/SSE). A harness that reuses an
|
||||
existing transport class is fully covered. A harness that invents a novel
|
||||
transport degrades its transport-dependent probes to `SKIPPED`/`UNKNOWN` until a
|
||||
driver for that class exists — but model-agnostic dimensions (streaming, MCP,
|
||||
policy, cost) stay covered regardless.
|
||||
Behavioral probes call semantic driver methods such as `run_basic_turn`,
|
||||
`run_tool_turn`, `run_policy_turn`, and `run_interrupt_turn`. Drivers own the
|
||||
transport-specific mechanism; probes interpret a common `TurnResult`.
|
||||
|
||||
This is why "run the bench, see all verdicts, zero code" is true *for any
|
||||
harness reusing a known transport class*, and honest about the one case where it
|
||||
is not.
|
||||
Three drivers exist:
|
||||
|
||||
## Phasing
|
||||
- `full-server` is the SDK-family default. It drives a real server and runner,
|
||||
uses a server-dispatched builtin for tool probes, and observes fixed
|
||||
ALLOW/ASK/DENY policies.
|
||||
- `native-tui` drives a resident vendor CLI in a runner-owned tmux pane through
|
||||
the server session API. It observes vendor tool calls and tool-call DENY via
|
||||
the native policy hook. ALLOW/ASK are not yet implemented.
|
||||
- `sdk-inproc` drives the harness wrap directly. It is selected by `--fast` and
|
||||
provides cheaper wrap-level coverage, but no server-side policy surface.
|
||||
|
||||
- **MVP (P0).** Layer 0 profile/manifest + Layer 1 offline conformance + Layer 2
|
||||
P0 probes (basic turn, streaming, MCP/tool-calling, interrupt, policy DENY,
|
||||
model override) + the **SDK in-proc transport driver** + report with `DRIFT`
|
||||
column. Wire the SDK harnesses already in `HARNESS_PROBES` (claude-sdk, codex,
|
||||
pi, openai-agents).
|
||||
- **P1.** Steering, live-queue, resume/fork, elicitation ASK, reasoning, images,
|
||||
cost, compaction; the tmux / app-server / HTTP-SSE transport drivers; the
|
||||
remaining SDK + all native harness profiles.
|
||||
A `SKIPPED` verdict therefore means the behavior was not measurable in that
|
||||
transport or environment, not that the harness lacks the capability. A novel
|
||||
transport class still requires a driver, but harnesses reusing one of these
|
||||
families flow through the existing probes without per-harness probe code.
|
||||
|
||||
## Current state (shipped)
|
||||
|
||||
The bench on `main` includes:
|
||||
|
||||
- **Six P0 probes:** Basic turn, Streaming, Tool calling, Policy DENY, Model
|
||||
override, and Interrupt.
|
||||
- **Three P1 probes:** 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.
|
||||
- **Automatic live selection:** without an explicit mode, the CLI runs live
|
||||
when credentials are resolvable and otherwise renders the declared matrix.
|
||||
`--live` and `--no-live` force either mode. Credentials are derived like
|
||||
`omni run`; `--profile` is only an override.
|
||||
- **Concurrent execution and shared infrastructure:** `--jobs` runs harnesses
|
||||
concurrently while preserving report order, and full-server harnesses share
|
||||
one server/runner pair within a run.
|
||||
- **Structured progress and reports:** plain or Rich live progress plus terminal,
|
||||
Markdown, JSON, and optional report-file output.
|
||||
- **Capability-derived registration:** official SDK and native profiles derive
|
||||
from `harness_capabilities()` and existing e2e metadata. Session-item parsing,
|
||||
config loading, free-port selection, and polling helpers are shared rather
|
||||
than duplicated.
|
||||
|
||||
### Not yet wired
|
||||
|
||||
- Registry-driven server seeding for community native UI agents.
|
||||
- Steering, live queue, resume/fork, reasoning, images, and compaction probes.
|
||||
- Automatic provisioning of vendor login/provider configuration for native
|
||||
harnesses; unavailable environments skip cleanly.
|
||||
|
||||
## CI integration
|
||||
|
||||
@@ -266,37 +301,29 @@ is not.
|
||||
## Running the bench and reading the result
|
||||
|
||||
```
|
||||
# Offline: the declared matrix, no creds, every harness. Fast.
|
||||
python -m tests.harness_bench
|
||||
# Declared matrix only, with no credentials.
|
||||
python -m tests.harness_bench --no-live
|
||||
|
||||
# Live: probe one harness against a gateway profile.
|
||||
python -m tests.harness_bench --harness codex-native --profile oss
|
||||
# Auto-live when configured or ambient credentials are available.
|
||||
python -m tests.harness_bench --harness codex
|
||||
|
||||
# Live: probe every official harness (SDK + native) sequentially.
|
||||
python -m tests.harness_bench --profile oss
|
||||
# Force a named profile and probe several harnesses concurrently.
|
||||
python -m tests.harness_bench --profile oss --jobs 4 --rich
|
||||
|
||||
# A community harness that ships its own BenchProfile.
|
||||
python -m tests.harness_bench --harness mypkg.harness:PROFILE --profile oss
|
||||
python -m tests.harness_bench --harness mypkg.harness:PROFILE --live
|
||||
```
|
||||
|
||||
**You do not need to live-probe every harness on every host — and you cannot.**
|
||||
Each native harness needs its own vendor CLI logged in (a login the bench
|
||||
cannot provision), so no single host has them all. The two layers split the
|
||||
work:
|
||||
Without `--live` or `--no-live`, resolvable credentials select live mode and
|
||||
missing credentials select the offline declared matrix. Native harnesses also
|
||||
need their vendor CLI installed and logged in; the bench cannot provision those
|
||||
accounts, so unavailable harnesses skip without aborting the run.
|
||||
|
||||
- **Offline conformance** already covers every harness in CI — registration,
|
||||
the declared matrix, capability derivation. No host access needed.
|
||||
- **Live probes** only answer "does observed behavior match the declaration?"
|
||||
You get value from live-probing a harness where the declaration is unverified
|
||||
or might be wrong — not from chasing 100% coverage on one box.
|
||||
|
||||
Run the full set on whatever host you have (`--profile oss`); harnesses whose
|
||||
vendor CLI is absent or logged out **skip cleanly** (they do not fail or abort
|
||||
the run). Read two signals only: any `!!` DRIFT, and any harness you *can* run
|
||||
that shows an unexpected `✗` / `·`. A single live run is a spot-check, not a
|
||||
gate — live probes are non-deterministic (model behavior, timing), so re-run
|
||||
before treating one `·`/timeout as a regression. Drift coverage is cumulative:
|
||||
each host that has harness X logged in contributes a live check for X.
|
||||
Offline conformance covers every registered harness in CI. Live runs are
|
||||
spot-checks of observed behavior and can vary with model behavior and timing;
|
||||
re-run an isolated timeout or skip before treating it as a regression. The
|
||||
signals that matter most are `DRIFT` and repeatable unexpected
|
||||
`UNSUPPORTED`/`PARTIAL` verdicts on a runnable harness.
|
||||
|
||||
## Streaming is a binary declared capability
|
||||
|
||||
@@ -306,16 +333,89 @@ a harness either forwards token-level deltas (`SUPPORTED`) or it does not
|
||||
returns it for the ambiguous coalesced-single-delta case against a `SUPPORTED`
|
||||
declaration. It is **never a declared value**. Declaring a non-streaming
|
||||
harness as `PARTIAL` drifts against reality, because the probe reports zero
|
||||
deltas as `UNSUPPORTED`, not `PARTIAL`. This bit the transcript-mirror natives
|
||||
(kiro/goose/qwen/hermes/cursor/kimi/pi), which deliver each complete assistant
|
||||
message rather than streaming deltas: they declare `streaming=False` →
|
||||
`UNSUPPORTED`, matching what the probe observes.
|
||||
deltas as `UNSUPPORTED`, not `PARTIAL`.
|
||||
|
||||
**Declare `streaming=False` only from a live observation of 0 deltas** — a
|
||||
static "the forwarder posts no delta" grep is *not* sufficient. That grep once
|
||||
flipped seven natives to `False` in one batch; a live run then showed
|
||||
pi-native streams (7 deltas) despite having no delta-posting forwarder, so the
|
||||
flip was reverted. Only three natives are declared non-streaming today, each
|
||||
live-verified at 0 deltas: **kiro-native, cursor-native, qwen-native**. The
|
||||
rest default to `streaming=True` (the honest default: if one turns out not to
|
||||
stream, the bench flags a real drift on the next run, rather than a false
|
||||
`False` that silently drifts the moment the harness *does* stream).
|
||||
|
||||
## Which transport exercises which dimension
|
||||
|
||||
| Dimension | `sdk-inproc` (`--fast`) | `full-server` (SDK default) | `native-tui` |
|
||||
|---|---|---|---|
|
||||
| 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 |
|
||||
| Policy DENY | Not observable | Fixed policy blocks the builtin | Session CEL policy triggers the native policy hook |
|
||||
| Policy ALLOW / ASK | Not observable | Fixed policy; ASK observes and resolves an elicitation | Temporary session CEL policy; ASK observes and resolves an elicitation |
|
||||
| Cost tracking | Completed-response usage when forwarded | Session snapshot usage/cost | Session snapshot when the vendor forwards usage |
|
||||
|
||||
`full-server` remains the SDK default because it covers the deployed server
|
||||
path and all three policy actions. `--fast` trades that policy coverage for
|
||||
lower startup cost. `native-tui` now has real Tool calling and all three policy
|
||||
action probes through the native hook path.
|
||||
|
||||
## Plugin seamlessness: where it is and isn't
|
||||
|
||||
The original goal (option B) was that a *community* harness ships a
|
||||
`BenchProfile` and runs with `--harness <name>` and no bench edits. For the
|
||||
**bench itself, that holds**: profile resolution, capability derivation, and
|
||||
`native_vendor()` all read `harness_capabilities()`, which discovers community
|
||||
plugins via entry points. A plugged-in harness needs zero bench code to be
|
||||
recognized.
|
||||
|
||||
The seam is **one level down, in the omnigent server**. A native harness is
|
||||
only drivable once the server has seeded a built-in `<harness>-native-ui`
|
||||
agent, and that seeding is a **hardcoded list** in
|
||||
`server/app.py:_ensure_default_agents` — one `_ensure_default_<harness>_agent()`
|
||||
call per harness. goose-native and hermes-native were in the capability
|
||||
registry but omitted from that list, so the bench (correctly) reported them
|
||||
`not auto-registered on the server` until the seeders were added.
|
||||
|
||||
So: **the bench is plugin-seamless; the server's native-agent seeding is not,
|
||||
and the bench inherits that seam.** A community native plugin today resolves in
|
||||
the bench, then fails at registration because nothing seeds its UI agent. The
|
||||
clean fix is to make `_ensure_default_agents` iterate `native_agents()` from
|
||||
the registry (which already includes plugins) instead of a hardcoded call list
|
||||
— then native harnesses and plugins register automatically. This is the highest
|
||||
-leverage remaining item: it is the difference between "the bench is plugin-
|
||||
ready" and "a plugged-in native harness works end to end".
|
||||
|
||||
## The self-enforcing table in practice (drift case studies)
|
||||
|
||||
`reconcile()` turns a false capability declaration into a `DRIFT`. This is not
|
||||
theoretical — the bench caught several real declaration errors this way, each
|
||||
resolved by correcting the *source* (the capability model), not the bench:
|
||||
|
||||
- **kiro-native / streaming.** Declared `SUPPORTED`, observed 0 deltas
|
||||
(`!!✓>✗`). kiro mirrors each complete assistant message rather than streaming
|
||||
tokens. Corrected to `streaming=False`.
|
||||
- **pi-native / streaming (a fixed over-correction).** A static grep had flipped
|
||||
pi to `False`; a live run showed it streams 7 deltas (`!!✗>✓`) despite having
|
||||
no delta-posting forwarder. Reverted to `True`. This is why the rule is
|
||||
"declare `False` only from a live 0-delta observation" — the grep lied.
|
||||
- **cursor-native / streaming + provisioning.** cursor could not provision at
|
||||
all until the `lazy_chat` fix (its `external_session_id` is created by the
|
||||
first message, not at launch, so gating on it pre-turn deadlocked). Once
|
||||
runnable, it observed 0 deltas → `streaming=False`.
|
||||
- **qwen-native / streaming.** Observed 0 deltas → `streaming=False`.
|
||||
|
||||
The pattern each time: the bench detects the mismatch, a live probe pins which
|
||||
side is wrong, and the capability model is corrected — not the bench massaged to
|
||||
agree with it.
|
||||
|
||||
## Open items
|
||||
|
||||
- Exact `BenchProfile` field set and whether it subsumes `HarnessProbe` or wraps
|
||||
it.
|
||||
- Whether the manifest fully retires the spreadsheet, or the bench diffs against
|
||||
an exported CSV so the sheet stays canonical during transition.
|
||||
- Native transport drivers are the larger half of the work; sequence them by
|
||||
which harnesses matter most for the matrix.
|
||||
- **Registry-driven native-agent seeding** — replace the hardcoded server
|
||||
seeding list with registry iteration so community native harnesses work end
|
||||
to end after plugin installation.
|
||||
- **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/fork, reasoning,
|
||||
images, and compaction.
|
||||
|
||||
@@ -273,9 +273,10 @@ os_env:
|
||||
sandbox:
|
||||
type: none
|
||||
|
||||
# Generic shell terminal for long-running processes (dev servers, watchers, log
|
||||
# Generic shell terminals for long-running processes (dev servers, watchers, log
|
||||
# tails) and ad-hoc shell when sys_os_shell's blocking model doesn't fit. NOT
|
||||
# for launching coding agents / sub-agents — those go through sys_session_send.
|
||||
# bash and zsh are both offered; the "+ New shell" affordance picks between them.
|
||||
terminals:
|
||||
shell:
|
||||
command: bash
|
||||
@@ -285,6 +286,14 @@ terminals:
|
||||
cwd: .
|
||||
sandbox:
|
||||
type: none
|
||||
zsh:
|
||||
command: zsh
|
||||
allow_cwd_override: true
|
||||
os_env:
|
||||
type: caller_process
|
||||
cwd: .
|
||||
sandbox:
|
||||
type: none
|
||||
|
||||
tools:
|
||||
# Coding sub-agents — see agents/<name>/. claude_code, codex, opencode,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Remy — an assistant that remembers.
|
||||
#
|
||||
# Remy uses Hindsight long-term memory so what you tell it in one run is
|
||||
# available in every future run. Before answering it recalls what it already
|
||||
# knows about you; when you share a durable fact it retains it; and it can
|
||||
# reflect over everything it has stored.
|
||||
#
|
||||
# Memory is keyed by the agent id, so all of Remy's runs share one memory bank.
|
||||
#
|
||||
# Setup:
|
||||
# pip install 'omnigent[memory]'
|
||||
# export HINDSIGHT_API_KEY=hsk_... # https://ui.hindsight.vectorize.io
|
||||
#
|
||||
# Usage:
|
||||
# omnigent run examples/remy
|
||||
#
|
||||
# Remy runs on the Claude Agent SDK harness, so configure a Claude provider
|
||||
# first (e.g. `omnigent setup`, or export ANTHROPIC_API_KEY).
|
||||
|
||||
spec_version: 1
|
||||
name: remy
|
||||
description: >-
|
||||
A helpful assistant with long-term memory. Remy recalls what it already knows
|
||||
before answering, retains durable facts you share, and can reflect over its
|
||||
memory — powered by Hindsight.
|
||||
|
||||
executor:
|
||||
type: omnigent
|
||||
config:
|
||||
harness: claude-sdk
|
||||
|
||||
prompt: |
|
||||
You are Remy, a helpful assistant with long-term memory powered by Hindsight.
|
||||
|
||||
Your conversation context is wiped between sessions — the ONLY way you
|
||||
remember anything is by calling these tools. Acknowledging a fact in chat does
|
||||
NOT save it.
|
||||
|
||||
Use your memory tools deliberately:
|
||||
- Call `hindsight_recall` BEFORE answering anything that might depend on what
|
||||
you already know about the user or past conversations.
|
||||
- When the user shares a durable fact, preference, or decision — or asks you
|
||||
to remember something — you MUST call `hindsight_retain`. Never say you have
|
||||
saved or will remember something unless `hindsight_retain` actually ran and
|
||||
returned success in this turn.
|
||||
- Call `hindsight_reflect` when the user asks you to summarize or reason about
|
||||
what you know overall, rather than retrieve specific facts.
|
||||
|
||||
Weave recalled memories into your answer naturally — don't dump raw tool
|
||||
output. If recall returns nothing relevant, just answer normally.
|
||||
|
||||
# bank_id pins a stable, human-readable memory bank. Omit it and the bank
|
||||
# defaults to the agent id (per-agent isolation) — handy, but opaque to find
|
||||
# in Hindsight. A fixed name keeps Remy's memory in one easy-to-inspect bank.
|
||||
tools:
|
||||
builtins:
|
||||
- name: hindsight_recall
|
||||
api_key: ${HINDSIGHT_API_KEY}
|
||||
bank_id: remy
|
||||
- name: hindsight_retain
|
||||
api_key: ${HINDSIGHT_API_KEY}
|
||||
bank_id: remy
|
||||
- name: hindsight_reflect
|
||||
api_key: ${HINDSIGHT_API_KEY}
|
||||
bank_id: remy
|
||||
@@ -91,6 +91,66 @@ def default_shell_argv(command: str) -> list[str]:
|
||||
return [sh, "-c", command]
|
||||
|
||||
|
||||
#: Interactive shells we honor from ``$SHELL`` for a user terminal. Anything
|
||||
#: outside this set (or a ``$SHELL`` that doesn't resolve on PATH) falls back to
|
||||
#: bash for a predictable pane.
|
||||
_KNOWN_INTERACTIVE_SHELLS = frozenset({"bash", "zsh", "fish", "sh", "dash", "ksh", "tcsh"})
|
||||
|
||||
#: Mainstream interactive shells we proactively offer as launch choices (the
|
||||
#: "New shell" picker), in display order. The user's ``$SHELL`` is always
|
||||
#: offered first regardless (see :func:`installed_interactive_shells`); this is
|
||||
#: the set of well-known alternatives we surface beyond it.
|
||||
_OFFERED_INTERACTIVE_SHELLS = ("bash", "zsh", "fish")
|
||||
|
||||
|
||||
def default_interactive_shell() -> str:
|
||||
"""
|
||||
Basename of the user's login shell for an interactive terminal.
|
||||
|
||||
Reads ``$SHELL`` and keeps its basename when it names a known shell that
|
||||
resolves on PATH; otherwise falls back to ``"bash"``. Returns a basename
|
||||
(not the absolute ``$SHELL`` path) so it stays PATH-resolvable when the
|
||||
terminal launches under a runner on a different host than the one that read
|
||||
the env.
|
||||
|
||||
:returns: A shell basename such as ``"zsh"``, ``"fish"``, or ``"bash"``.
|
||||
"""
|
||||
if IS_WINDOWS:
|
||||
# Native tmux/PTY terminals are unsupported on Windows anyway.
|
||||
return "bash"
|
||||
import shutil
|
||||
|
||||
name = os.path.basename(os.environ.get("SHELL", "")).strip()
|
||||
if name in _KNOWN_INTERACTIVE_SHELLS and shutil.which(name):
|
||||
return name
|
||||
return "bash"
|
||||
|
||||
|
||||
def installed_interactive_shells() -> list[str]:
|
||||
"""
|
||||
Ordered, deduped shell basenames to offer for a new interactive terminal.
|
||||
|
||||
The user's login shell (:func:`default_interactive_shell`) comes first — so
|
||||
the "New shell" affordance can treat entry ``[0]`` as the click default —
|
||||
followed by any mainstream alternatives (bash/zsh/fish) that resolve on
|
||||
PATH. Always non-empty (the default is always present, and bash is the
|
||||
ultimate fallback).
|
||||
|
||||
:returns: Basenames such as ``["zsh", "bash", "fish"]`` — the default first.
|
||||
"""
|
||||
ordered = [default_interactive_shell()]
|
||||
if IS_WINDOWS:
|
||||
# Native tmux/PTY terminals are unsupported on Windows anyway; the lone
|
||||
# bash default from above is all we can meaningfully offer.
|
||||
return ordered
|
||||
import shutil
|
||||
|
||||
for name in _OFFERED_INTERACTIVE_SHELLS:
|
||||
if name not in ordered and shutil.which(name):
|
||||
ordered.append(name)
|
||||
return ordered
|
||||
|
||||
|
||||
def stable_user_id() -> str:
|
||||
"""
|
||||
A stable, filesystem-safe token identifying the current OS user.
|
||||
|
||||
@@ -124,6 +124,7 @@ from omnigent.host.daemon_launch import (
|
||||
wait_for_host_online,
|
||||
wait_for_runner_online,
|
||||
)
|
||||
from omnigent.native_coding_agents import native_shell_terminal_spec
|
||||
from omnigent.native_terminal import (
|
||||
DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S,
|
||||
)
|
||||
@@ -313,18 +314,9 @@ def _materialize_antigravity_agent_spec(tmpdir: Path) -> Path:
|
||||
# the ``sys_terminal_*`` family to the wrapped agy (the relay's gate is
|
||||
# a non-empty ``terminals:`` block on this spec). This also feeds the
|
||||
# web-UI new-terminal affordance (``server/routes/sessions.py``), so it
|
||||
# is not inert even independent of the relay.
|
||||
"terminals": {
|
||||
"shell": {
|
||||
"command": "bash",
|
||||
"allow_cwd_override": True,
|
||||
"os_env": {
|
||||
"type": "caller_process",
|
||||
"cwd": ".",
|
||||
"sandbox": {"type": "none"},
|
||||
},
|
||||
},
|
||||
},
|
||||
# is not inert even independent of the relay. Its command follows the
|
||||
# user's ``$SHELL`` (zsh/fish/bash).
|
||||
"terminals": native_shell_terminal_spec(),
|
||||
}
|
||||
yaml_path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8")
|
||||
return yaml_path
|
||||
|
||||
@@ -272,6 +272,35 @@ def _freshest_waiting(
|
||||
return same_kind
|
||||
|
||||
|
||||
def _waiting_step_at(
|
||||
steps: list[dict[str, object]],
|
||||
*,
|
||||
trajectory_id: str,
|
||||
step_index: int,
|
||||
) -> PendingInteraction | None:
|
||||
"""
|
||||
Return the WAITING interaction at an exact ``(trajectory_id, step_index)``.
|
||||
|
||||
Pins verdict delivery to the step the elicitation was surfaced for rather than
|
||||
the freshest WAITING step (which could be a different gate that appeared
|
||||
meanwhile). Returns ``None`` when that step is no longer WAITING (timed out or
|
||||
answered), letting the caller fall back to ``_freshest_waiting`` for agy's
|
||||
same-gate timeout-retry.
|
||||
|
||||
:param steps: Trajectory steps snapshot.
|
||||
:param trajectory_id: The surfaced step's trajectory id.
|
||||
:param step_index: The surfaced step's index.
|
||||
:returns: The matching WAITING :class:`PendingInteraction`, or ``None``.
|
||||
"""
|
||||
for step in steps:
|
||||
pending = pending_interaction(step)
|
||||
if pending is None:
|
||||
continue
|
||||
if pending["trajectory_id"] == trajectory_id and pending["step_index"] == step_index:
|
||||
return pending
|
||||
return None
|
||||
|
||||
|
||||
async def bridge_interaction(
|
||||
cascade_id: str,
|
||||
pending: PendingInteraction,
|
||||
@@ -351,9 +380,16 @@ async def bridge_interaction(
|
||||
)
|
||||
return
|
||||
|
||||
# Re-read the freshest WAITING step BEFORE delivering: the captured ids
|
||||
# may be stale if agy timed out + retried while the human deliberated.
|
||||
fresh = _freshest_waiting(await get_steps(), kind=current["kind"])
|
||||
# Re-read the steps BEFORE delivering: the captured ids may be stale if agy
|
||||
# timed out + retried while the human deliberated. PIN to the step we
|
||||
# surfaced if it is STILL WAITING — deliver THIS verdict to THAT gate, never
|
||||
# to a different higher-index gate that appeared meanwhile (#1472 review).
|
||||
# Only when our captured step is gone (timed out → ERROR) do we fall back to
|
||||
# the freshest WAITING, which is agy's same-gate timeout-retry (§2.1).
|
||||
steps = await get_steps()
|
||||
fresh = _waiting_step_at(
|
||||
steps, trajectory_id=current["trajectory_id"], step_index=current["step_index"]
|
||||
) or _freshest_waiting(steps, kind=current["kind"])
|
||||
if fresh is None:
|
||||
_logger.warning(
|
||||
"agy elicitation %s resolved but no WAITING step remains to "
|
||||
|
||||
@@ -123,6 +123,18 @@ _DEFAULT_ROTATION_INTERVAL_S = 3.0
|
||||
# exception, never a clean immediate return.
|
||||
_STREAM_REENTRY_BACKOFF_S = 0.5
|
||||
|
||||
# Teardown drain passes for the interaction bridge + chained re-scan tasks. Cancelling
|
||||
# a bridge stops it scheduling a re-scan and vice versa, so the chain collapses fast;
|
||||
# a few extra passes give slack without risking an unbounded loop.
|
||||
_INTERACTION_DRAIN_PASSES = 4
|
||||
|
||||
# Re-scan poll retry budget. The bridge-clear re-scan is the sole backstop on the
|
||||
# healthy-stream path (agy emits no frame while parked on a deferred gate and the poll
|
||||
# loop is only the failure fallback), so a single swallowed error would strand the gate
|
||||
# forever. Retry a bounded number of times before giving up.
|
||||
_INTERACTION_RESCAN_POLL_ATTEMPTS = 3
|
||||
_INTERACTION_RESCAN_POLL_BACKOFF_S = 0.2
|
||||
|
||||
# POST retry policy, kept identical to the transcript forwarder's so mirrored
|
||||
# items are delivered with the same transient-retry semantics. Conversation
|
||||
# items persist with a random primary key and are NOT deduped server-side, so an
|
||||
@@ -1030,11 +1042,25 @@ async def supervise_reader(
|
||||
body_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await body_task
|
||||
active = state.interaction_task
|
||||
if active is not None and not active.done():
|
||||
active.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await active
|
||||
# Drain the bridge and any chained re-scan tasks. Yield once per pass so
|
||||
# a normally-completed bridge's pending ``_clear_slot`` callback lands in
|
||||
# ``interaction_rescans`` before the snapshot — otherwise it escapes the
|
||||
# drain and runs post-teardown. Suppress all exceptions (including
|
||||
# CancelledError) to avoid aborting the drain with orphaned tasks.
|
||||
for _ in range(_INTERACTION_DRAIN_PASSES):
|
||||
await asyncio.sleep(0)
|
||||
inflight = [
|
||||
pending
|
||||
for pending in (state.interaction_task, *state.interaction_rescans)
|
||||
if pending is not None and not pending.done()
|
||||
]
|
||||
if not inflight:
|
||||
break
|
||||
for pending in inflight:
|
||||
pending.cancel()
|
||||
for pending in inflight:
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await pending
|
||||
|
||||
# Report how many committed steps (turns) this run mirrored for the bound
|
||||
# cascade. The caller uses a count of 0 to distinguish "first TUI-minted
|
||||
@@ -1105,6 +1131,9 @@ class _ReaderState:
|
||||
is later seen NO LONGER WAITING (answered in the agy TUI, or agy timed
|
||||
out) to WITHDRAW the still-parked web card (#1200, direction 2). An entry
|
||||
is removed once withdrawn so the withdraw posts at most once.
|
||||
:param interaction_rescans: In-flight re-scan tasks scheduled by a bridge's
|
||||
done-callback to surface a WAITING gate deferred while the bridge ran.
|
||||
Held as strong refs so they are not GC'd mid-run; cancelled on teardown.
|
||||
"""
|
||||
|
||||
allocator: _ToolCallIdAllocator
|
||||
@@ -1121,6 +1150,7 @@ class _ReaderState:
|
||||
cumulative_cache_read_input_tokens: int = 0
|
||||
interaction_task: asyncio.Task[None] | None = None
|
||||
surfaced_elicitations: dict[_StepKey, str] = field(default_factory=dict)
|
||||
interaction_rescans: set[asyncio.Task[None]] = field(default_factory=set)
|
||||
|
||||
|
||||
async def _poll_loop(
|
||||
@@ -1719,8 +1749,11 @@ def _maybe_handle_interaction(
|
||||
``bridge_interaction`` already owns those retries via its own freshest-WAITING
|
||||
re-read, so spawning a second task for a retry step would surface a duplicate
|
||||
elicitation and a competing delivery. Subsequent WAITING steps are skipped
|
||||
while a task is active; its done-callback then clears the slot so a genuinely
|
||||
new later interaction can fire.
|
||||
while a task is active; its done-callback then clears the slot AND re-scans the
|
||||
freshest steps (:func:`_resurface_pending_interaction`) so a genuinely-NEW gate
|
||||
deferred during that window — e.g. the next segment of a chained ``a && b``
|
||||
command, each gated separately — is surfaced even when no further stream frame
|
||||
will carry it (agy stays parked on that gate, emitting none) (#1472).
|
||||
|
||||
The callback gets the SAME ``cascade_id`` + ``port`` (from ``state``) the
|
||||
reader discovered, so the bridge targets agy's live conversation without
|
||||
@@ -1760,23 +1793,107 @@ def _maybe_handle_interaction(
|
||||
async def _run_bridge() -> None:
|
||||
await on_pending_interaction(cascade_id, state.port, pending)
|
||||
|
||||
def _clear_slot(completed: asyncio.Task[None]) -> None:
|
||||
if state.interaction_task is completed:
|
||||
state.interaction_task = None
|
||||
if not completed.cancelled():
|
||||
exc = completed.exception()
|
||||
def _clear_rescan(done: asyncio.Task[None]) -> None:
|
||||
state.interaction_rescans.discard(done)
|
||||
if not done.cancelled():
|
||||
exc = done.exception()
|
||||
if exc is not None:
|
||||
_logger.warning(
|
||||
"agy interaction bridge task failed (cascade=%s): %r",
|
||||
"agy interaction re-scan task failed (cascade=%s): %r",
|
||||
cascade_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
def _clear_slot(completed: asyncio.Task[None]) -> None:
|
||||
if state.interaction_task is completed:
|
||||
state.interaction_task = None
|
||||
if completed.cancelled():
|
||||
# Reader teardown cancelled the bridge — the run is ending, so do NOT
|
||||
# spawn a re-scan (teardown drains these tasks; a fresh one would race it).
|
||||
return
|
||||
exc = completed.exception()
|
||||
if exc is not None:
|
||||
_logger.warning(
|
||||
"agy interaction bridge task failed (cascade=%s): %r",
|
||||
cascade_id,
|
||||
exc,
|
||||
)
|
||||
# Re-scan for a WAITING gate the single-in-flight guard deferred while this
|
||||
# bridge ran (e.g. the next segment of a chained ``a && b`` command). agy
|
||||
# emits no frame while parked on that gate, so without the re-scan it hangs.
|
||||
# ``state.interacted`` makes already-surfaced steps no-ops.
|
||||
rescan = asyncio.create_task(
|
||||
_resurface_pending_interaction(
|
||||
cascade_id=cascade_id,
|
||||
state=state,
|
||||
on_pending_interaction=on_pending_interaction,
|
||||
),
|
||||
name="antigravity-interaction-rescan",
|
||||
)
|
||||
state.interaction_rescans.add(rescan)
|
||||
rescan.add_done_callback(_clear_rescan)
|
||||
|
||||
task = asyncio.create_task(_run_bridge(), name="antigravity-interaction-bridge")
|
||||
state.interaction_task = task
|
||||
task.add_done_callback(_clear_slot)
|
||||
|
||||
|
||||
async def _resurface_pending_interaction(
|
||||
*,
|
||||
cascade_id: str,
|
||||
state: _ReaderState,
|
||||
on_pending_interaction: OnPendingInteraction,
|
||||
) -> None:
|
||||
"""
|
||||
Re-surface a WAITING interaction the single-in-flight guard deferred.
|
||||
|
||||
Scheduled by ``_clear_slot`` after a bridge finishes. Re-reads the freshest
|
||||
trajectory snapshot and re-dispatches every step through
|
||||
:func:`_maybe_handle_interaction`; ``state.interacted`` makes already-surfaced
|
||||
steps no-ops, so only the deferred gate fires. That gate spawns the next bridge,
|
||||
whose clear re-scans again, draining a chain of sequential gates one at a time.
|
||||
|
||||
The snapshot read is retried up to :data:`_INTERACTION_RESCAN_POLL_ATTEMPTS` times
|
||||
because this is the sole backstop on the healthy-stream path (agy emits no frame
|
||||
while parked on the deferred gate; the poll loop is only the failure fallback).
|
||||
|
||||
:param cascade_id: agy cascade id (equal to the conversation id).
|
||||
:param state: Per-run reader state.
|
||||
:param on_pending_interaction: Async callback for a distinct interaction.
|
||||
"""
|
||||
steps: list[dict[str, object]] | None = None
|
||||
for attempt in range(_INTERACTION_RESCAN_POLL_ATTEMPTS):
|
||||
try:
|
||||
steps = await asyncio.to_thread(get_trajectory_steps, state.port, cascade_id)
|
||||
break
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
last = attempt == _INTERACTION_RESCAN_POLL_ATTEMPTS - 1
|
||||
_logger.warning(
|
||||
"agy interaction re-scan poll failed (cascade=%s, port=%s, attempt=%d/%d)%s: %r",
|
||||
cascade_id,
|
||||
state.port,
|
||||
attempt + 1,
|
||||
_INTERACTION_RESCAN_POLL_ATTEMPTS,
|
||||
"; giving up — the poll fallback or a later frame must catch the deferred gate"
|
||||
if last
|
||||
else "; retrying",
|
||||
exc,
|
||||
)
|
||||
if last:
|
||||
return
|
||||
await _sleep(_INTERACTION_RESCAN_POLL_BACKOFF_S)
|
||||
if steps is None: # pragma: no cover - the loop returns on the last failure
|
||||
return
|
||||
for step in steps:
|
||||
_maybe_handle_interaction(
|
||||
step,
|
||||
key=_step_key(step),
|
||||
cascade_id=cascade_id,
|
||||
state=state,
|
||||
on_pending_interaction=on_pending_interaction,
|
||||
)
|
||||
|
||||
|
||||
async def _maybe_withdraw_interaction(
|
||||
step: dict[str, object],
|
||||
*,
|
||||
|
||||
+69
-15
@@ -89,6 +89,7 @@ from omnigent.host.daemon_launch import (
|
||||
wait_for_host_online,
|
||||
wait_for_runner_online,
|
||||
)
|
||||
from omnigent.native_coding_agents import native_shell_terminal_spec
|
||||
from omnigent.native_terminal import (
|
||||
DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S,
|
||||
)
|
||||
@@ -149,6 +150,17 @@ _UCODE_CLAUDE_TIER_TO_ENV: dict[str, str] = {
|
||||
"sonnet": _ANTHROPIC_DEFAULT_SONNET_MODEL_ENV,
|
||||
"haiku": _ANTHROPIC_DEFAULT_HAIKU_MODEL_ENV,
|
||||
}
|
||||
# The 4 family aliases above pin one model ID each. Claude Code has exactly
|
||||
# one more independently-selectable /model picker slot beyond those
|
||||
# families — ANTHROPIC_CUSTOM_MODEL_OPTION — used here to surface Sonnet 5
|
||||
# as an opt-in *alongside* the "sonnet" alias, which stays pinned to the
|
||||
# workspace's existing default Sonnet (4.6). This keeps the default Sonnet
|
||||
# unchanged and adds the newer generation as a separate, explicit choice.
|
||||
# See https://code.claude.com/docs/en/model-config#custom-model-options
|
||||
_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV = "ANTHROPIC_CUSTOM_MODEL_OPTION"
|
||||
_ANTHROPIC_CUSTOM_MODEL_OPTION_NAME_ENV = "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"
|
||||
_UCODE_CLAUDE_CUSTOM_TIER = "sonnet_5"
|
||||
_UCODE_CLAUDE_CUSTOM_TIER_LABEL = "Sonnet 5"
|
||||
_DEFAULT_UCODE_AUTH_REFRESH_INTERVAL_MS = 900_000
|
||||
_SESSION_LABELS = {
|
||||
"omnigent.ui": "terminal",
|
||||
@@ -310,6 +322,16 @@ 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
|
||||
|
||||
|
||||
@@ -1449,6 +1471,10 @@ def _ucode_config_for_profile(profile: str | None) -> ClaudeNativeUcodeConfig |
|
||||
model_id = workspace_state.claude_models.get(tier)
|
||||
if model_id:
|
||||
env[env_var] = model_id
|
||||
custom_model_id = workspace_state.claude_models.get(_UCODE_CLAUDE_CUSTOM_TIER)
|
||||
if custom_model_id:
|
||||
env[_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV] = custom_model_id
|
||||
env[_ANTHROPIC_CUSTOM_MODEL_OPTION_NAME_ENV] = _UCODE_CLAUDE_CUSTOM_TIER_LABEL
|
||||
# When ucode caches no model, default it so Claude Code doesn't fall back
|
||||
# to its host-config model (an Anthropic-direct id the gateway rejects).
|
||||
return ClaudeNativeUcodeConfig(
|
||||
@@ -1770,20 +1796,10 @@ def _materialize_claude_agent_spec(tmpdir: Path) -> Path:
|
||||
# Declare a default shell terminal so the relay advertises the
|
||||
# ``sys_terminal_*`` family to the wrapped Claude Code (the
|
||||
# relay's gate is a non-empty ``terminals:`` block on this
|
||||
# spec). Caller process / no sandbox matches the ``os_env``
|
||||
# stance above — the native CLI already runs unsandboxed on
|
||||
# the user's workspace.
|
||||
"terminals": {
|
||||
"shell": {
|
||||
"command": "bash",
|
||||
"allow_cwd_override": True,
|
||||
"os_env": {
|
||||
"type": "caller_process",
|
||||
"cwd": ".",
|
||||
"sandbox": {"type": "none"},
|
||||
},
|
||||
},
|
||||
},
|
||||
# spec). Its command follows the user's ``$SHELL`` (zsh/fish/bash);
|
||||
# caller process / no sandbox matches the ``os_env`` stance above —
|
||||
# the native CLI already runs unsandboxed on the user's workspace.
|
||||
"terminals": native_shell_terminal_spec(),
|
||||
}
|
||||
yaml_path.write_text(yaml.safe_dump(raw, sort_keys=False))
|
||||
return yaml_path
|
||||
@@ -3525,6 +3541,14 @@ def _claude_transcript_records_from_session_items(
|
||||
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
|
||||
"uuid": boundary_uuid,
|
||||
"level": "info",
|
||||
# Claude scans every compact_boundary and destructures
|
||||
# compactMetadata; a missing object crashes /compact
|
||||
# (and auto-compact) on resume. token_count is the
|
||||
# post-compaction summary size.
|
||||
"compactMetadata": {
|
||||
"trigger": "auto",
|
||||
"postTokens": item.get("token_count"),
|
||||
},
|
||||
}
|
||||
)
|
||||
parent_uuid = boundary_uuid
|
||||
@@ -3657,7 +3681,7 @@ def _claude_transcript_record_from_session_item(
|
||||
}
|
||||
],
|
||||
}
|
||||
extra["toolUseResult"] = output
|
||||
extra["toolUseResult"] = _json_safe_tool_use_result(output)
|
||||
else:
|
||||
return None
|
||||
return {
|
||||
@@ -3777,6 +3801,36 @@ def _json_object_from_string(value: object) -> dict[str, Any]:
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def _json_safe_tool_use_result(output: str) -> str:
|
||||
"""
|
||||
Return a ``toolUseResult`` value Claude Code can ``JSON.parse``.
|
||||
|
||||
Some built-in result renderers (notably ``TaskOutput``) call
|
||||
``JSON.parse`` on ``toolUseResult`` when the transcript is resumed.
|
||||
A raw display string such as ``"<retrieval_status>timeout</...>"``
|
||||
throws ``JSON Parse error: Unrecognized token '<'`` at TUI boot,
|
||||
before the input prompt renders — so the whole resume fails and the
|
||||
first web-UI message is never delivered.
|
||||
|
||||
Outputs that are already JSON (e.g. an image content-block array)
|
||||
pass through verbatim; anything else is wrapped as a JSON string
|
||||
literal so the parse always succeeds. The plain-text output still
|
||||
lives verbatim in the ``tool_result`` content block, so this does
|
||||
not change what the model or the web UI sees.
|
||||
|
||||
:param output: The tool result string synthesized for the
|
||||
transcript, e.g. ``"<retrieval_status>timeout</...>"`` or
|
||||
``'[{"type":"image",...}]'``.
|
||||
:returns: A JSON-parseable string for the record's
|
||||
``toolUseResult`` field.
|
||||
"""
|
||||
try:
|
||||
json.loads(output)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return json.dumps(output)
|
||||
return output
|
||||
|
||||
|
||||
def _preflight_local_tools(command: str) -> None:
|
||||
"""
|
||||
Verify local executables required by the native Claude wrapper.
|
||||
|
||||
@@ -124,6 +124,14 @@ _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.
|
||||
_BOX_RULE_CHARS = frozenset("─━╭╮╰╯│┃╌╍")
|
||||
# How many trailing non-empty lines to scan for the prompt glyph. The
|
||||
# input box sits near the bottom of the pane; scanning only the tail
|
||||
# avoids false positives from the glyph appearing in scrollback output.
|
||||
@@ -277,11 +285,18 @@ def _trusted_parent_for_bridge_dir(target: Path) -> Path:
|
||||
# bridge-owned directories below it.
|
||||
return _absolute_syntactic_path(kiro_root.parent.parent)
|
||||
|
||||
# Headless ACP harnesses (acp / goose / qwen) put their Omnigent-MCP relay
|
||||
# bridge below ``$TMPDIR/omnigent-<uid>/acp-mcp`` (same uid-scoped shape as
|
||||
# cursor/qwen/hermes-native), so trust the uid-scoped temp dir's parent.
|
||||
acp_root = _absolute_syntactic_path(acp_mcp_bridge_root())
|
||||
if target.is_relative_to(acp_root):
|
||||
return _absolute_syntactic_path(acp_root.parent.parent)
|
||||
|
||||
raise RuntimeError(
|
||||
f"bridge dir {target!s} is not under an allowed bridge root "
|
||||
f"({claude_root!s}, {codex_root!s}, {cursor_root!s}, "
|
||||
f"{antigravity_root!s}, {qwen_root!s}, {hermes_root!s}, {opencode_root!s}, "
|
||||
f"{kiro_root!s})"
|
||||
f"{kiro_root!s}, {acp_root!s})"
|
||||
)
|
||||
|
||||
|
||||
@@ -700,6 +715,38 @@ def _ensure_secure_dir(target: Path) -> None:
|
||||
os.chmod(ancestor, 0o700)
|
||||
|
||||
|
||||
def acp_mcp_bridge_root() -> Path:
|
||||
"""Bridge root for the headless ACP harnesses' Omnigent-MCP relay.
|
||||
|
||||
Shares the uid-scoped temp parent with claude-native
|
||||
(``$TMPDIR/omnigent-<uid>/acp-mcp``). Used by the acp / goose / qwen
|
||||
executors' ``OmnigentAcpMcp`` relay so ``serve-mcp``'s bridge dir passes the
|
||||
:func:`_trusted_parent_for_bridge_dir` secure-root check.
|
||||
|
||||
:returns: The ACP-MCP bridge root directory (not created here).
|
||||
"""
|
||||
return _BRIDGE_ROOT_PARENT / "acp-mcp"
|
||||
|
||||
|
||||
def prepare_acp_mcp_bridge_dir() -> Path:
|
||||
"""Create a fresh, secure per-relay bridge dir for an ACP harness.
|
||||
|
||||
Returns a unique owner-only directory under :func:`acp_mcp_bridge_root` with
|
||||
a minimal token-only ``bridge.json`` — so the shared ``serve-mcp`` serves
|
||||
ONLY the relay tools (no raw ``sys_os_*`` filesystem tools; the ACP agent
|
||||
owns those). The caller's relay writes ``tool_relay.json`` here and points
|
||||
``serve-mcp`` at the directory.
|
||||
|
||||
:returns: The prepared bridge directory path.
|
||||
"""
|
||||
bridge_dir = acp_mcp_bridge_root() / secrets.token_hex(8)
|
||||
_ensure_secure_dir(bridge_dir)
|
||||
config_path = bridge_dir / _CONFIG_FILE
|
||||
if not config_path.exists():
|
||||
_write_json_file(config_path, {"token": secrets.token_urlsafe(32)})
|
||||
return bridge_dir
|
||||
|
||||
|
||||
def bridge_dir_for_bridge_id(bridge_id: str) -> Path:
|
||||
"""
|
||||
Return the deterministic bridge directory for a Claude-native bridge.
|
||||
@@ -1043,6 +1090,9 @@ def build_hook_settings(
|
||||
ap_server_url: str | None = None,
|
||||
ap_auth_headers: dict[str, str] | None = None,
|
||||
api_key_helper: str | None = None,
|
||||
launch_model: str | None = None,
|
||||
launch_permission_mode: str | None = None,
|
||||
launch_effort: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build invocation-local Claude Code hook settings.
|
||||
@@ -1062,6 +1112,15 @@ def build_hook_settings(
|
||||
:param api_key_helper: Optional Claude Code ``apiKeyHelper``
|
||||
command from ucode state, e.g. ``"databricks auth token
|
||||
--host https://example.databricks.com ..."``.
|
||||
:param launch_model: Effective launch model from ``--model``. Mirrored
|
||||
into the invocation-local settings sidecar so a wrapped Claude Code
|
||||
re-exec that preserves ``--settings`` but rebuilds argv cannot fall
|
||||
back to the user's global default model.
|
||||
:param launch_permission_mode: Effective launch permission mode from
|
||||
``--permission-mode``. Mirrored into ``permissions.defaultMode``
|
||||
for the same re-exec hardening.
|
||||
:param launch_effort: Effective launch effort from ``--effort``.
|
||||
Mirrored into ``effortLevel`` for restart/re-exec parity.
|
||||
:returns: JSON-serializable Claude settings fragment.
|
||||
"""
|
||||
python = python_executable or sys.executable
|
||||
@@ -1247,6 +1306,12 @@ def build_hook_settings(
|
||||
# prompts, since both fire UserPromptSubmit.
|
||||
hooks["UserPromptSubmit"].append({"hooks": [evaluate_policy_hook]})
|
||||
settings: dict[str, Any] = {"hooks": hooks}
|
||||
if launch_model:
|
||||
settings["model"] = launch_model
|
||||
if launch_permission_mode:
|
||||
settings["permissions"] = {"defaultMode": launch_permission_mode}
|
||||
if launch_effort and launch_effort in CLAUDE_EFFORTS:
|
||||
settings["effortLevel"] = launch_effort
|
||||
if api_key_helper:
|
||||
settings["apiKeyHelper"] = api_key_helper
|
||||
# Override Claude Code's statusLine so we receive its stdin (the
|
||||
@@ -1343,6 +1408,9 @@ def augment_claude_args(
|
||||
ap_server_url=ap_server_url,
|
||||
ap_auth_headers=ap_auth_headers,
|
||||
api_key_helper=api_key_helper,
|
||||
launch_model=_arg_value(claude_args, "--model"),
|
||||
launch_permission_mode=_arg_value(claude_args, "--permission-mode"),
|
||||
launch_effort=_arg_value(claude_args, "--effort"),
|
||||
)
|
||||
args = _merge_disallowed_tools(list(claude_args), _OMNIGENT_DISALLOWED_TOOLS)
|
||||
args.extend(
|
||||
@@ -1363,6 +1431,32 @@ def augment_claude_args(
|
||||
return args
|
||||
|
||||
|
||||
def _arg_value(args: tuple[str, ...], flag: str) -> str | None:
|
||||
"""Return the effective CLI flag value from ``args``.
|
||||
|
||||
Supports both ``--flag value`` and ``--flag=value`` spellings. When a
|
||||
flag appears more than once, the last valid occurrence wins, matching the
|
||||
usual CLI precedence for repeated long options.
|
||||
|
||||
:param args: Claude CLI args, e.g. ``("--model", "sonnet")``.
|
||||
:param flag: Long flag to read, e.g. ``"--model"``.
|
||||
:returns: The flag value, or ``None`` when absent/empty.
|
||||
"""
|
||||
joined_prefix = f"{flag}="
|
||||
value: str | None = None
|
||||
for idx, arg in enumerate(args):
|
||||
if arg.startswith(joined_prefix):
|
||||
candidate = arg[len(joined_prefix) :]
|
||||
if candidate:
|
||||
value = candidate
|
||||
continue
|
||||
if arg == flag and idx + 1 < len(args):
|
||||
candidate = args[idx + 1]
|
||||
if candidate and not candidate.startswith("--"):
|
||||
value = candidate
|
||||
return value
|
||||
|
||||
|
||||
def _merge_disallowed_tools(args: list[str], extra: tuple[str, ...]) -> list[str]:
|
||||
"""
|
||||
Add ``extra`` tool names to a ``--disallowedTools`` flag in ``args``.
|
||||
@@ -2831,11 +2925,77 @@ def _claude_prompt_rendered(pane: str) -> bool:
|
||||
positives from the glyph appearing in scrollback (e.g. echoed in a
|
||||
prior response), since the live input box always sits at the bottom.
|
||||
|
||||
A mid-turn injection grows the footer with running-state rows (a
|
||||
``○ Explore …`` subagent line, extra spinners) that can push ``❯``
|
||||
past that window — arbitrarily far, since a subagent fan-out adds one
|
||||
row per concurrent subagent. To reach it at any depth without also
|
||||
matching a scrollback echo, a glyph above the window counts only when
|
||||
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()]
|
||||
return any(_CLAUDE_PROMPT_GLYPH in line for line in non_empty[-_PROMPT_SCAN_TAIL_LINES:])
|
||||
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
|
||||
# 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
|
||||
# ``○ Explore …`` rows), so no fixed window can bound it; the box rule
|
||||
# is a reliable structural signal at any depth, and `capture-pane -p`
|
||||
# returns only the visible pane, so this stays within one screen.
|
||||
for idx, line in enumerate(non_empty):
|
||||
if _CLAUDE_PROMPT_GLYPH not in line:
|
||||
continue
|
||||
if any(_is_box_rule(rule) for rule in non_empty[idx + 1 :]):
|
||||
return True
|
||||
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.
|
||||
|
||||
Claude Code frames its input box with rows of ``─`` (plus corner
|
||||
glyphs). Such a rule below ``❯`` marks the live input box, letting
|
||||
the readiness scan reach a prompt buried under a tall running-turn
|
||||
footer without matching a bare ``❯`` echoed into scrollback.
|
||||
|
||||
:param line: A single pane line, e.g. ``"──────────"``.
|
||||
:returns: ``True`` when the line is predominantly box-rule glyphs.
|
||||
"""
|
||||
stripped = line.strip()
|
||||
return len(stripped) >= 3 and all(ch in _BOX_RULE_CHARS for ch in stripped)
|
||||
|
||||
|
||||
def _submit_needle(content: str) -> str:
|
||||
@@ -2950,25 +3110,47 @@ def _wait_for_claude_prompt_ready(
|
||||
:param timeout_s: Seconds to wait for the prompt, e.g. ``30.0``.
|
||||
:returns: None.
|
||||
:raises RuntimeError: If the prompt never renders within
|
||||
*timeout_s* (Claude failed to boot). The message carries the
|
||||
tail of the captured pane (see :func:`_format_terminal_failure_tail`)
|
||||
so Claude Code's own startup output surfaces in the caller's error.
|
||||
*timeout_s* (Claude failed to boot). The message carries a poll
|
||||
count, how many of those polls saw an empty capture, and the tail
|
||||
of the last non-empty capture the loop actually observed (see
|
||||
:func:`_format_terminal_failure_tail`) so the true failure mode —
|
||||
a startup crash, a torn/empty capture under a mid-turn repaint, or
|
||||
a box that never appeared — is diagnosable from the error alone.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
if _claude_prompt_rendered(_capture_pane(socket_path, tmux_target)):
|
||||
polls = 0
|
||||
empty_polls = 0
|
||||
# Keep the last non-empty capture the loop actually saw, not a fresh
|
||||
# capture taken after the deadline. A post-timeout re-capture can show
|
||||
# a different (often healthier-looking) frame than any decision the
|
||||
# loop made — e.g. the input box repainting just as the turn settles —
|
||||
# which misrepresents why the gate failed. Attaching what was observed
|
||||
# while it mattered keeps the error honest.
|
||||
last_nonempty = ""
|
||||
# Poll at least once even at timeout_s=0: a single readiness check is
|
||||
# still meaningful, and it guarantees a capture to attach on failure.
|
||||
while True:
|
||||
pane = _capture_pane(socket_path, tmux_target)
|
||||
polls += 1
|
||||
if pane.strip():
|
||||
last_nonempty = pane
|
||||
else:
|
||||
empty_polls += 1
|
||||
if _claude_prompt_rendered(pane):
|
||||
return
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
time.sleep(_CLAUDE_READY_POLL_INTERVAL_S)
|
||||
# Timed out: Claude Code never rendered its input prompt. Capture the
|
||||
# pane one last time and attach its tail so the real cause — often a
|
||||
# startup crash like a ``JSON Parse error`` — surfaces in the web UI
|
||||
# error banner this raises into, instead of only a generic timeout
|
||||
# the user has to open the terminal to diagnose.
|
||||
pane = _capture_pane(socket_path, tmux_target)
|
||||
# Timed out. The poll/empty-capture counts separate the failure modes:
|
||||
# mostly-empty captures point at a torn read under a busy repaint (the
|
||||
# session is alive but capture-pane came back blank); non-empty captures
|
||||
# with no box point at Claude never rendering the prompt (a boot crash,
|
||||
# e.g. a ``JSON Parse error``, whose text the tail then surfaces).
|
||||
raise RuntimeError(
|
||||
f"Claude Code terminal did not become ready within {timeout_s}s "
|
||||
"(input prompt never rendered). The message was not delivered."
|
||||
+ _format_terminal_failure_tail(pane)
|
||||
f"(input prompt never rendered in {polls} polls, "
|
||||
f"{empty_polls} empty captures). The message was not delivered."
|
||||
+ _format_terminal_failure_tail(last_nonempty)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2839,6 +2839,31 @@ async def _ensure_state_for_transcript(
|
||||
return state
|
||||
|
||||
|
||||
def _turn_has_assistant_output(items: list[ClaudeTranscriptItem], response_id: str) -> bool:
|
||||
"""
|
||||
Whether ``response_id`` has assistant-generated output among ``items``.
|
||||
|
||||
The turn-start ``running`` edge should open a streaming turn only for an id
|
||||
that a later ``Stop``/``StopFailure`` hook will close — i.e. one produced by
|
||||
an actual LLM turn. Assistant text (``message`` with ``role=assistant``) and
|
||||
tool calls (``function_call``) qualify; a ``slash_command`` (``/model``,
|
||||
``/effort``) or ``terminal_command`` (``!cmd``) item opens an id with no LLM
|
||||
turn behind it, so it must not.
|
||||
|
||||
:param items: Transcript items read this poll.
|
||||
:param response_id: The current turn's response id.
|
||||
:returns: ``True`` when an assistant-output item carries ``response_id``.
|
||||
"""
|
||||
for item in items:
|
||||
if item.response_id != response_id:
|
||||
continue
|
||||
if item.item_type == "function_call":
|
||||
return True
|
||||
if item.item_type == "message" and item.data.get("role") == "assistant":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _forward_available_items(
|
||||
*,
|
||||
client: httpx.AsyncClient,
|
||||
@@ -2902,9 +2927,19 @@ async def _forward_available_items(
|
||||
# status post must not abort item forwarding (the items below are the
|
||||
# primary payload); the turn-end idle/failed edge still carries the id to
|
||||
# close the lifecycle, and the badge is unaffected either way.
|
||||
#
|
||||
# Only open the streaming turn for an id that has ASSISTANT output in this
|
||||
# poll's items. A surfaced CLI built-in (``/model``, ``/effort``) or a
|
||||
# ``!cmd`` becomes a slash_command / terminal_command item that opens its
|
||||
# own response id but runs no LLM turn, so no ``Stop`` hook ever fires to
|
||||
# close it — a ``running`` opened for it would strand the web composer in
|
||||
# its "Stop"/busy state until the next real message. A skill that DOES
|
||||
# trigger an LLM turn shares its id with the assistant text it produces, so
|
||||
# ``running`` still fires — one poll later, when that output appears.
|
||||
if (
|
||||
current_response_id is not None
|
||||
and dedupe.posted_running_response_id != current_response_id
|
||||
and _turn_has_assistant_output(items, current_response_id)
|
||||
):
|
||||
try:
|
||||
await post_external_session_status(
|
||||
@@ -3611,22 +3646,29 @@ def _model_alias_for(model: str | None) -> str | None:
|
||||
Collapse a concrete Claude model id to the picker's tier alias.
|
||||
|
||||
The web model picker speaks Claude Code's version-agnostic aliases
|
||||
(``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"haiku"``); the
|
||||
(``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"haiku"``), plus the one
|
||||
extra concrete-id slot ``"sonnet_5"`` (see
|
||||
:data:`omnigent.claude_native._UCODE_CLAUDE_CUSTOM_TIER`) for the newer
|
||||
Sonnet generation offered alongside the default ``"sonnet"`` tier; the
|
||||
transcript records the resolved concrete id (e.g.
|
||||
``"claude-opus-4-8"`` or ``"databricks-claude-sonnet-4-6"``).
|
||||
``"claude-opus-4-8"`` or ``"databricks-claude-sonnet-5"``).
|
||||
Mapping to the tier keeps the mirrored value in the picker's
|
||||
vocabulary and makes a web→TUI round-trip a no-op.
|
||||
vocabulary and makes a web→TUI round-trip a no-op. The older Sonnet
|
||||
(``sonnet-4-6``) collapses to the generic ``"sonnet"`` alias — it is the
|
||||
default that row is bound to.
|
||||
|
||||
:param model: Concrete model id from the transcript, e.g.
|
||||
``"claude-opus-4-8"``; ``None`` when none observed yet.
|
||||
:returns: ``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"haiku"``
|
||||
when the id carries a known tier token, else ``None`` (the
|
||||
caller skips the post rather than surface an id the picker
|
||||
:returns: ``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"sonnet_5"`` /
|
||||
``"haiku"`` when the id carries a known tier token, else ``None``
|
||||
(the caller skips the post rather than surface an id the picker
|
||||
can't render).
|
||||
"""
|
||||
if not model:
|
||||
return None
|
||||
lowered = model.lower()
|
||||
if "sonnet-5" in lowered or "sonnet_5" in lowered:
|
||||
return "sonnet_5"
|
||||
for tier in ("fable", "opus", "sonnet", "haiku"):
|
||||
if tier in lowered:
|
||||
return tier
|
||||
|
||||
@@ -1039,23 +1039,25 @@ def _main_evaluate_policy(argv: list[str]) -> int:
|
||||
# The session is governed (active id + ap_server_url) and we have a
|
||||
# policy-relevant event: from here a failure to obtain a usable verdict
|
||||
# fails CLOSED for the tool-call gate (see ``fail_closed_hook_output``).
|
||||
def _fail_closed() -> int:
|
||||
out = fail_closed_hook_output(hook_event)
|
||||
reauth = policy_hook_reauth(ap_server_url, headers)
|
||||
|
||||
def _fail_closed(detail: str | None = None) -> int:
|
||||
out = fail_closed_hook_output(hook_event, detail)
|
||||
if out is not None:
|
||||
sys.stdout.write(json.dumps(out))
|
||||
return 0
|
||||
|
||||
url = f"{ap_server_url.rstrip('/')}/v1/sessions/{url_component(session_id)}/policies/evaluate"
|
||||
resp = post_evaluate_with_retry(
|
||||
resp, api_error = post_evaluate_with_retry(
|
||||
url,
|
||||
headers,
|
||||
eval_request,
|
||||
_EVALUATE_POLICY_TIMEOUT_S,
|
||||
"evaluate-policy hook",
|
||||
reauth=policy_hook_reauth(ap_server_url, headers),
|
||||
reauth=reauth,
|
||||
)
|
||||
if resp is None:
|
||||
return _fail_closed()
|
||||
return _fail_closed(api_error or reauth.failure_reason)
|
||||
if not resp.content:
|
||||
print("omnigent evaluate-policy hook: empty Omnigent response", file=sys.stderr)
|
||||
return _fail_closed()
|
||||
|
||||
+496
-56
@@ -33,6 +33,11 @@ from omnigent._platform import IS_WINDOWS, resolve_repo_symlink
|
||||
from omnigent._startup_profile import StartupProfiler
|
||||
from omnigent.cli_sandbox import lakebox as _lakebox_alias_group
|
||||
from omnigent.cli_sandbox import sandbox as _sandbox_group
|
||||
from omnigent.config import (
|
||||
global_config_path,
|
||||
load_global_config,
|
||||
load_local_config,
|
||||
)
|
||||
from omnigent.harness_aliases import canonicalize_harness
|
||||
from omnigent.host.local_server import (
|
||||
_DEFAULT_LOCAL_PORT,
|
||||
@@ -233,11 +238,14 @@ _DAEMON_RECONNECT_GRACE_S = 5.0
|
||||
_DAEMON_REUSE_MIN_AGE_S = 6.0
|
||||
|
||||
# How long uvicorn waits for active connections (WebSocket, SSE) after
|
||||
# SIGTERM before force-closing them. 30 s gives in-flight responses time
|
||||
# to drain while still guaranteeing the port is released promptly.
|
||||
# SIGTERM before force-closing them. SSE streams signal themselves via
|
||||
# session_stream.shutdown_all() in _ShutdownSignalingServer.shutdown(),
|
||||
# so the main remaining consumers of this window are WebSocket tunnels
|
||||
# that need a moment to drain. 5 s is enough for a clean tunnel teardown
|
||||
# while keeping Ctrl-C feeling instant.
|
||||
# Overridable via OMNIGENT_SERVER_SHUTDOWN_TIMEOUT_S for deployments that
|
||||
# need a longer drain window (e.g. large file uploads).
|
||||
_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S_DEFAULT = 30
|
||||
_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S_DEFAULT = 5
|
||||
_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S = int(
|
||||
os.environ.get(
|
||||
"OMNIGENT_SERVER_SHUTDOWN_TIMEOUT_S",
|
||||
@@ -296,9 +304,7 @@ def _effective_global_config_path() -> Path:
|
||||
:returns: ``$OMNIGENT_CONFIG_HOME/config.yaml`` when the env
|
||||
override is set, otherwise :data:`_GLOBAL_CONFIG_PATH`.
|
||||
"""
|
||||
if config_home := os.environ.get(_CONFIG_HOME_ENV_VAR):
|
||||
return Path(config_home) / "config.yaml"
|
||||
return _GLOBAL_CONFIG_PATH
|
||||
return global_config_path(_GLOBAL_CONFIG_PATH)
|
||||
|
||||
|
||||
def _display_path(path: Path) -> str:
|
||||
@@ -357,12 +363,7 @@ def _load_global_config() -> dict[str, Any]: # type: ignore[explicit-any]
|
||||
``{"default_agent": "examples/hello_world.yaml",
|
||||
"auth": {"type": "databricks", "profile": "oss"}}``.
|
||||
"""
|
||||
path = _effective_global_config_path()
|
||||
if not path.exists():
|
||||
return {}
|
||||
with open(path) as f:
|
||||
raw: dict[str, Any] = yaml.safe_load(f) or {} # type: ignore[explicit-any]
|
||||
return raw
|
||||
return load_global_config(_effective_global_config_path())
|
||||
|
||||
|
||||
def _load_local_config() -> dict[str, Any]: # type: ignore[explicit-any]
|
||||
@@ -373,12 +374,7 @@ def _load_local_config() -> dict[str, Any]: # type: ignore[explicit-any]
|
||||
|
||||
:returns: Parsed YAML as a dict.
|
||||
"""
|
||||
path = Path.cwd() / _LOCAL_CONFIG_RELPATH
|
||||
if not path.exists():
|
||||
return {}
|
||||
with open(path) as f:
|
||||
raw: dict[str, Any] = yaml.safe_load(f) or {} # type: ignore[explicit-any]
|
||||
return raw
|
||||
return load_local_config(Path.cwd() / _LOCAL_CONFIG_RELPATH)
|
||||
|
||||
|
||||
def _load_effective_config() -> dict[str, Any]: # type: ignore[explicit-any]
|
||||
@@ -1196,6 +1192,7 @@ _CLICK_SUBCOMMANDS: frozenset[str] = frozenset(
|
||||
"qwen",
|
||||
"resume",
|
||||
"run",
|
||||
"session",
|
||||
"sandbox",
|
||||
"server",
|
||||
"setup",
|
||||
@@ -2597,8 +2594,8 @@ def _start_cli_runner_process(
|
||||
``~/.omnigent/logs`` location; tests should pass a
|
||||
temporary directory to avoid writing to the developer's
|
||||
real home.
|
||||
:param prewarm_spec_path: Optional YAML path; the runner spawns
|
||||
its MCPs during the upload window. See designs/RUNNER_MCP.md.
|
||||
:param prewarm_spec_path: Optional YAML path; the runner registers
|
||||
its MCP routing metadata during startup without opening transports.
|
||||
:param isolate_session: ``True`` for shared-host runners;
|
||||
enables per-session workspace isolation so each
|
||||
session gets its own subdirectory. ``False`` (default)
|
||||
@@ -2972,6 +2969,7 @@ def server(
|
||||
port = _picked
|
||||
|
||||
import uvicorn
|
||||
import uvicorn.server
|
||||
|
||||
from omnigent.runner.transports.ws_tunnel.limits import (
|
||||
RUNNER_TUNNEL_MAX_MESSAGE_BYTES,
|
||||
@@ -3220,34 +3218,71 @@ def server(
|
||||
# this foreground server instead of tearing it down on a spurious
|
||||
# sig mismatch.
|
||||
register_local_server(port)
|
||||
|
||||
class _ShutdownSignalingServer(uvicorn.server.Server):
|
||||
"""uvicorn.Server that signals active SSE subscribers before the
|
||||
graceful-shutdown wait starts.
|
||||
|
||||
uvicorn calls ``Server.shutdown()`` in this order:
|
||||
1. close listening sockets / call connection.shutdown()
|
||||
2. ``asyncio.wait_for(_wait_tasks_to_complete(), timeout=…)``
|
||||
3. force-cancel remaining tasks on timeout
|
||||
4. run the ASGI lifespan shutdown handler
|
||||
|
||||
The ASGI lifespan ``finally`` block runs at step 4 — too late. SSE
|
||||
generators waiting on a heartbeat tick are already force-cancelled by
|
||||
step 3, which produces spurious ``CancelledError`` tracebacks.
|
||||
Overriding here lets us drain SSE streams before step 2 so they exit
|
||||
cleanly within the graceful window.
|
||||
"""
|
||||
|
||||
async def shutdown(self, sockets=None) -> None: # type: ignore[override]
|
||||
import asyncio as _asyncio
|
||||
|
||||
from omnigent.runtime import session_stream as _session_stream
|
||||
|
||||
_session_stream.shutdown_all()
|
||||
# Yield to the event loop so generators can consume _DONE,
|
||||
# flush their final "data: [DONE]\n\n" chunk, and exit before
|
||||
# super().shutdown() calls connection.shutdown() / transport.close().
|
||||
# Without this pause the generators write to an already-closing
|
||||
# transport, leaving connections open past the graceful window.
|
||||
await _asyncio.sleep(0)
|
||||
await super().shutdown(sockets)
|
||||
|
||||
_config = uvicorn.Config(
|
||||
app,
|
||||
host=host,
|
||||
port=port,
|
||||
log_config=_server_uvicorn_log_config(),
|
||||
ws_max_size=RUNNER_TUNNEL_MAX_MESSAGE_BYTES,
|
||||
# Server side of the runner/host tunnels' protocol keepalive, aligned
|
||||
# to the 90 s app-level budget instead of uvicorn's 20 s default that
|
||||
# drops a busy-but-healthy tunnel with 1011 — issue #1116.
|
||||
#
|
||||
# uvicorn's ws_ping_* is server-global (no per-route override), so this
|
||||
# 30 s/90 s budget also applies to the app's other WebSocket routes —
|
||||
# /v1/sessions/updates (browser stream) and .../terminals/{id}/attach.
|
||||
# Deliberate and acceptable: for an IDLE such socket the protocol
|
||||
# PING/PONG is the only half-open detector (the sessions-updates
|
||||
# heartbeat is a server->client send, and an idle terminal has no
|
||||
# traffic), so widening it means a dead idle browser/terminal socket is
|
||||
# reaped at worst ~120 s (30 s interval + 90 s timeout) instead of
|
||||
# ~40 s — a slightly later half-open cleanup (e.g. the out-of-process
|
||||
# terminal-attach proxy holds its runner socket + tmux child ~80 s
|
||||
# longer), bounded and eventually reaped, not a leak or correctness
|
||||
# change. The tunnels are the sockets that actually need the looser
|
||||
# budget (issue #1116).
|
||||
ws_ping_interval=TUNNEL_KEEPALIVE_PING_INTERVAL_S,
|
||||
ws_ping_timeout=TUNNEL_KEEPALIVE_PING_TIMEOUT_S,
|
||||
timeout_graceful_shutdown=_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S,
|
||||
)
|
||||
try:
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=host,
|
||||
port=port,
|
||||
log_config=_server_uvicorn_log_config(),
|
||||
ws_max_size=RUNNER_TUNNEL_MAX_MESSAGE_BYTES,
|
||||
# Server side of the runner/host tunnels' protocol keepalive, aligned
|
||||
# to the 90 s app-level budget instead of uvicorn's 20 s default that
|
||||
# drops a busy-but-healthy tunnel with 1011 — issue #1116.
|
||||
#
|
||||
# uvicorn's ws_ping_* is server-global (no per-route override), so this
|
||||
# 30 s/90 s budget also applies to the app's other WebSocket routes —
|
||||
# /v1/sessions/updates (browser stream) and .../terminals/{id}/attach.
|
||||
# Deliberate and acceptable: for an IDLE such socket the protocol
|
||||
# PING/PONG is the only half-open detector (the sessions-updates
|
||||
# heartbeat is a server->client send, and an idle terminal has no
|
||||
# traffic), so widening it means a dead idle browser/terminal socket is
|
||||
# reaped at worst ~120 s (30 s interval + 90 s timeout) instead of
|
||||
# ~40 s — a slightly later half-open cleanup (e.g. the out-of-process
|
||||
# terminal-attach proxy holds its runner socket + tmux child ~80 s
|
||||
# longer), bounded and eventually reaped, not a leak or correctness
|
||||
# change. The tunnels are the sockets that actually need the looser
|
||||
# budget (issue #1116).
|
||||
ws_ping_interval=TUNNEL_KEEPALIVE_PING_INTERVAL_S,
|
||||
ws_ping_timeout=TUNNEL_KEEPALIVE_PING_TIMEOUT_S,
|
||||
timeout_graceful_shutdown=_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S,
|
||||
)
|
||||
_ShutdownSignalingServer(_config).run()
|
||||
except KeyboardInterrupt:
|
||||
# uvicorn.run() swallows KeyboardInterrupt; match that behaviour so
|
||||
# a Ctrl-C exit doesn't print Click's "Aborted!" or exit non-zero.
|
||||
pass
|
||||
finally:
|
||||
if _is_canonical_local_server:
|
||||
clear_local_server_record()
|
||||
@@ -5501,6 +5536,112 @@ def resume(
|
||||
)
|
||||
|
||||
|
||||
@cli.group("session", invoke_without_command=True)
|
||||
@click.pass_context
|
||||
def session(ctx: click.Context) -> None:
|
||||
"""Manage Omnigent sessions.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
omnigent session export --id conv_abc123
|
||||
omnigent session export --id conv_abc123 --output transcript.jsonl
|
||||
omnigent session export --id conv_abc123 --server https://myserver.com
|
||||
"""
|
||||
if ctx.invoked_subcommand is None:
|
||||
click.echo(ctx.get_help())
|
||||
|
||||
|
||||
@session.command("export")
|
||||
@click.option(
|
||||
"--id",
|
||||
"session_id",
|
||||
required=True,
|
||||
metavar="SESSION_ID",
|
||||
help="Session ID to export, e.g. conv_abc123.",
|
||||
)
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
"output",
|
||||
default=None,
|
||||
metavar="FILE",
|
||||
help="Output file path. Defaults to <SESSION_ID>.jsonl in the current directory.",
|
||||
)
|
||||
@click.option(
|
||||
"--server",
|
||||
default=None,
|
||||
help=(
|
||||
"Omnigent server URL. "
|
||||
"Defaults to the configured server, or a local server already running."
|
||||
),
|
||||
)
|
||||
def session_export(session_id: str, output: str | None, server: str | None) -> None:
|
||||
"""Export a session transcript to a portable JSONL file.
|
||||
|
||||
Each line of the output is a JSON object. The first line carries
|
||||
the session metadata (``"record_type": "session_meta"``); every
|
||||
subsequent line is one conversation item
|
||||
(``"record_type": "item"``). The file preserves full turn order
|
||||
and can be re-imported with a future ``omnigent session import``.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
omnigent session export --id conv_abc123
|
||||
omnigent session export --id conv_abc123 --output my_session.jsonl
|
||||
omnigent session export --id conv_abc123 --server https://myserver.com
|
||||
"""
|
||||
import httpx
|
||||
|
||||
from omnigent.chat import _remote_headers
|
||||
|
||||
cfg = _load_effective_config()
|
||||
base_url = _resolve_attach_server(server, cfg.get("server"))
|
||||
if base_url is None:
|
||||
startup = ensure_local_omnigent_server()
|
||||
base_url = startup.url
|
||||
|
||||
base_url = base_url.rstrip("/")
|
||||
out_path = Path(output) if output else Path(f"{session_id}.jsonl")
|
||||
|
||||
with httpx.Client(
|
||||
base_url=base_url, headers=_remote_headers(server_url=base_url), timeout=30.0
|
||||
) as client:
|
||||
# Fetch session metadata (items fetched separately via pagination).
|
||||
resp = client.get(
|
||||
f"/v1/sessions/{session_id}",
|
||||
params={"include_items": "false", "include_liveness": "false"},
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
raise click.ClickException(f"Session {session_id!r} not found.")
|
||||
resp.raise_for_status()
|
||||
session_data = resp.json()
|
||||
|
||||
n_items = 0
|
||||
with out_path.open("w", encoding="utf-8") as fh:
|
||||
# First line: session metadata.
|
||||
meta_record = {"record_type": "session_meta", **session_data}
|
||||
fh.write(json.dumps(meta_record) + "\n")
|
||||
|
||||
# Remaining lines: items in ascending order, paginated.
|
||||
after: str | None = None
|
||||
while True:
|
||||
params: dict[str, str | int] = {"limit": 500, "order": "asc"}
|
||||
if after:
|
||||
params["after"] = after
|
||||
items_resp = client.get(f"/v1/sessions/{session_id}/items", params=params)
|
||||
items_resp.raise_for_status()
|
||||
page = items_resp.json()
|
||||
for item in page["data"]:
|
||||
item_record = {"record_type": "item", **item}
|
||||
fh.write(json.dumps(item_record) + "\n")
|
||||
n_items += 1
|
||||
if not page.get("has_more"):
|
||||
break
|
||||
after = page.get("last_id")
|
||||
|
||||
click.echo(f"Exported {n_items} item(s) from {session_id} to {out_path}")
|
||||
|
||||
|
||||
# Shared option help for ``run`` and the harness commands. These are the same
|
||||
# flags the legacy argparse CLI exposed — keeping them on the unified
|
||||
# click CLI so users don't regress when a YAML declares no executor
|
||||
@@ -5622,22 +5763,34 @@ def _materialize_harness_launcher_file(
|
||||
:raises click.ClickException: If *harness* is unsupported.
|
||||
"""
|
||||
_validate_harness(harness)
|
||||
display_name = harness
|
||||
harness = canonicalize_harness(harness) or harness
|
||||
canonical = canonicalize_harness(harness) or harness
|
||||
# An acp:<slug> harness id carries a colon: it canonicalizes to the base
|
||||
# `acp` harness, but the slug selects a user-configured ACP agent resolved
|
||||
# at spawn and must be preserved. So the effective harness id written to
|
||||
# executor.harness is the FULL acp:<slug> (keep the slug), or the canonical
|
||||
# id for every other harness (so aliases still resolve, e.g. kimi ->
|
||||
# kimi-code). The agent NAME and temp filename must be path-safe /
|
||||
# [a-zA-Z0-9_-]+, so the colon is sanitized there only.
|
||||
effective_harness = harness if canonical == "acp" and ":" in harness else canonical
|
||||
# Name preserves the user's input (matching the pre-acp behavior, e.g.
|
||||
# --harness claude -> name "claude"), sanitized for the colon so acp:<slug>
|
||||
# yields a valid [a-zA-Z0-9_-]+ name. Filename uses the canonical/effective
|
||||
# id (also colon-sanitized) as before.
|
||||
display_name = harness.replace(":", "-")
|
||||
|
||||
tmpdir = Path(tempfile.mkdtemp(prefix="omnigent-harness-launcher-"))
|
||||
yaml_path = tmpdir / f"{harness}.yaml"
|
||||
yaml_path = tmpdir / f"{effective_harness.replace(':', '-')}.yaml"
|
||||
|
||||
executor: dict[str, str] = {"harness": harness}
|
||||
executor: dict[str, str] = {"harness": effective_harness}
|
||||
if model is not None:
|
||||
executor["model"] = model
|
||||
|
||||
raw = {
|
||||
"name": display_name,
|
||||
"prompt": system_prompt or _default_harness_prompt(harness),
|
||||
"prompt": system_prompt or _default_harness_prompt(canonical),
|
||||
"executor": executor,
|
||||
}
|
||||
if harness in _OS_ENV_HARNESSES:
|
||||
if canonical in _OS_ENV_HARNESSES:
|
||||
raw["os_env"] = {"type": "caller_process", "sandbox": {"type": "none"}}
|
||||
yaml_path.write_text(yaml.safe_dump(raw, default_flow_style=False))
|
||||
return yaml_path
|
||||
@@ -9986,6 +10139,83 @@ def _manage_goose_harness() -> None:
|
||||
status = None
|
||||
|
||||
|
||||
def _print_acp_examples() -> None:
|
||||
"""Print example ACP-agent commands (Omnigent stores no credential)."""
|
||||
from omnigent.onboarding.interactive import console
|
||||
|
||||
console.print(
|
||||
"\n [bold]Custom ACP agents[/bold] — connect any agent that speaks the "
|
||||
"Agent Client Protocol ([underline]agentclientprotocol.com[/underline]).\n"
|
||||
" Omnigent stores no credential — log into each agent via its own CLI first.\n\n"
|
||||
" Example commands to paste:\n"
|
||||
" • Gemini CLI [bold]gemini --experimental-acp[/bold]\n"
|
||||
" • Qwen Code [bold]qwen --acp[/bold]\n"
|
||||
" • Goose [bold]goose acp[/bold]\n"
|
||||
" • Claude Code [bold]npx -y @zed-industries/claude-code-acp[/bold]\n"
|
||||
)
|
||||
|
||||
|
||||
def _add_acp_agent() -> None:
|
||||
"""Prompt for a new ACP agent and append it to the ``acp:`` config block.
|
||||
|
||||
Reached straight from the "Add custom ACP agent" overview row (no
|
||||
intermediate menu). Prints the paste-ready examples first, then prompts for
|
||||
name / command / optional model.
|
||||
"""
|
||||
from omnigent.onboarding.acp_auth import (
|
||||
AcpAgentEntry,
|
||||
acp_agents,
|
||||
acp_agents_settings,
|
||||
slugify,
|
||||
)
|
||||
from omnigent.onboarding.interactive import console, prompt_text
|
||||
|
||||
_print_acp_examples()
|
||||
name = prompt_text("Agent name (e.g. Gemini CLI)").strip()
|
||||
if not name:
|
||||
console.print(" [yellow]No name entered — nothing added.[/yellow]")
|
||||
return
|
||||
command = prompt_text("Command to launch (e.g. gemini --experimental-acp)").strip()
|
||||
if not command:
|
||||
console.print(" [yellow]No command entered — nothing added.[/yellow]")
|
||||
return
|
||||
model = (prompt_text("Model (optional — Enter to skip)", default="") or "").strip() or None
|
||||
|
||||
entries = list(acp_agents())
|
||||
entries.append(AcpAgentEntry(slug=slugify(name), name=name, command=command, model=model))
|
||||
_save_global_config(acp_agents_settings(entries))
|
||||
console.print(f" ✓ Added {name}")
|
||||
|
||||
|
||||
def _manage_acp_agent(slug: str) -> None:
|
||||
"""Per-agent drill-in for one configured ACP agent: remove it.
|
||||
|
||||
Reached by selecting the agent's own row in the configure-harnesses overview.
|
||||
A single-shot menu (Remove / Back) — Omnigent stores no credential, so there
|
||||
is nothing else to manage per agent yet.
|
||||
|
||||
:param slug: The agent's slug (see :func:`omnigent.onboarding.acp_auth.slugify`).
|
||||
"""
|
||||
from omnigent.onboarding.acp_auth import acp_agents, acp_agents_settings
|
||||
from omnigent.onboarding.interactive import console, select
|
||||
|
||||
agents = list(acp_agents())
|
||||
agent = next((a for a in agents if a.slug == slug), None)
|
||||
if agent is None:
|
||||
return
|
||||
suffix = f" · {agent.model}" if agent.model else ""
|
||||
header = f"{agent.name} — {agent.command}{suffix}"
|
||||
rows: list[_HarnessMenuRow] = [
|
||||
_HarnessMenuRow("Remove this agent", action="remove"),
|
||||
_HarnessMenuRow("← Back", action="back"),
|
||||
]
|
||||
idx = select(header, [r.label for r in rows], clear_on_exit=True)
|
||||
if idx < 0 or rows[idx].action == "back":
|
||||
return
|
||||
_save_global_config(acp_agents_settings([a for a in agents if a.slug != slug]))
|
||||
console.print(f" ✓ Removed {agent.name}")
|
||||
|
||||
|
||||
def _manage_hermes_harness() -> None:
|
||||
"""Run the level-2 loop for Hermes: ensure the CLI is installed.
|
||||
|
||||
@@ -11003,14 +11233,26 @@ def _run_configure_harnesses_interactive() -> None:
|
||||
# / ``kimi provider add`` → ~/.kimi/config.toml), so it dispatches to its
|
||||
# own drill-in rather than ``_manage_harness_providers``.
|
||||
_KIMI = "\x00kimi"
|
||||
# Sentinels for the generic-ACP rows. Each configured agent gets its own row
|
||||
# (``_ACP_AGENT_PREFIX + slug`` → per-agent remove drill-in); a single
|
||||
# ``_ACP_ADD`` row jumps straight into the add flow. Not a provider family —
|
||||
# each ACP agent owns its own auth.
|
||||
_ACP_ADD = "\x00acp-add"
|
||||
_ACP_AGENT_PREFIX = "\x00acp-agent:"
|
||||
families = [ANTHROPIC_FAMILY, OPENAI_FAMILY, PI_SURFACE]
|
||||
|
||||
# Status glyph + Rich color per readiness kind: "ready" is a configured,
|
||||
# launchable harness (green ✓); "missing" is an absent CLI/SDK (red ✗);
|
||||
# "warn" is installed-but-unconfigured (yellow ✗ — present, not usable
|
||||
# yet). The glyph leads the status, which sits in a left-aligned column
|
||||
# right of the names, so every ✓/✗ lines up in a single column.
|
||||
status_styles = {"ready": ("✓", "green"), "missing": ("✗", "red"), "warn": ("✗", "yellow")}
|
||||
# yet); "action" is a do-something row (e.g. Add) with no status glyph. The
|
||||
# glyph leads the status, which sits in a left-aligned column right of the
|
||||
# names, so every ✓/✗ lines up in a single column.
|
||||
status_styles = {
|
||||
"ready": ("✓", "green"),
|
||||
"missing": ("✗", "red"),
|
||||
"warn": ("✗", "yellow"),
|
||||
"action": ("", "cyan"),
|
||||
}
|
||||
|
||||
def _install_hint(command: str) -> str:
|
||||
# Selection-only tooltip. The command is escaped so a bracketed extra
|
||||
@@ -11271,6 +11513,34 @@ def _run_configure_harnesses_interactive() -> None:
|
||||
kimi_spec = harness_install_spec(KIMI_KEY)
|
||||
kimi_hint = (kimi_spec.install_hint if kimi_spec else None) or "see Kimi Code docs"
|
||||
rows.append((_KIMI, "Kimi Code", "Not installed", "missing", _install_hint(kimi_hint)))
|
||||
|
||||
# Custom ACP agents — the generic `acp` harness driving any user-configured
|
||||
# ACP-agent command. Each configured agent gets its own overview row
|
||||
# (select → per-agent remove drill-in) so it sits alongside the built-in
|
||||
# harnesses, followed by an "Add" row that jumps straight into the add
|
||||
# flow. Not gated on a binary — each agent owns its own install.
|
||||
from omnigent.onboarding.acp_auth import acp_config_summary
|
||||
|
||||
acp_summary = acp_config_summary()
|
||||
for agent in acp_summary.agents:
|
||||
rows.append(
|
||||
(
|
||||
_ACP_AGENT_PREFIX + agent.slug,
|
||||
agent.name,
|
||||
f"ACP · {agent.command}",
|
||||
"ready",
|
||||
"Select to remove this ACP agent.",
|
||||
)
|
||||
)
|
||||
rows.append(
|
||||
(
|
||||
_ACP_ADD,
|
||||
"Add custom ACP agent" if acp_summary.configured else "Custom ACP agent",
|
||||
"" if acp_summary.configured else "None configured",
|
||||
"action",
|
||||
"Add an ACP agent (gemini, qwen, goose, …).",
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
while True:
|
||||
@@ -11329,6 +11599,10 @@ def _run_configure_harnesses_interactive() -> None:
|
||||
_manage_opencode_harness()
|
||||
elif target == _GOOSE:
|
||||
_manage_goose_harness()
|
||||
elif target == _ACP_ADD:
|
||||
_add_acp_agent()
|
||||
elif isinstance(target, str) and target.startswith(_ACP_AGENT_PREFIX):
|
||||
_manage_acp_agent(target[len(_ACP_AGENT_PREFIX) :])
|
||||
elif target == _HERMES:
|
||||
_manage_hermes_harness()
|
||||
elif target == _KIRO:
|
||||
@@ -11633,6 +11907,172 @@ def debug_migrate_accounts_to_oidc(
|
||||
click.echo("\nDone. Flip OMNIGENT_AUTH_PROVIDER=oidc and restart.\n")
|
||||
|
||||
|
||||
@debug.command("logs")
|
||||
@click.option(
|
||||
"--type",
|
||||
"log_type",
|
||||
type=click.Choice(["runner", "host-runner", "server", "cli"], case_sensitive=False),
|
||||
default="runner",
|
||||
show_default=True,
|
||||
help="Log category: runner (local CLI runner via omnigent run), "
|
||||
"host-runner (runner spawned by a host daemon), "
|
||||
"server (local server), or cli (CLI diagnostics).",
|
||||
)
|
||||
@click.option(
|
||||
"--session",
|
||||
"session_id",
|
||||
default=None,
|
||||
metavar="SESSION_ID",
|
||||
help="Filter host-runner logs by session id, e.g. conv_abc123. "
|
||||
"Only applies to --type host-runner. Shows all log files for the "
|
||||
"session, oldest first.",
|
||||
)
|
||||
@click.option(
|
||||
"--list",
|
||||
"list_only",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="List available log files with size and timestamp instead of showing content.",
|
||||
)
|
||||
@click.option(
|
||||
"--lines",
|
||||
"-n",
|
||||
default=50,
|
||||
show_default=True,
|
||||
metavar="N",
|
||||
type=click.IntRange(min=0),
|
||||
help="Lines to show from the end of the log (0 = entire file). "
|
||||
"With --session, applied per file.",
|
||||
)
|
||||
@click.option(
|
||||
"--follow",
|
||||
"-f",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Follow the latest log file in real-time (like tail -f). "
|
||||
"With --session, follows the most recent file for the session. "
|
||||
"Not supported on Windows.",
|
||||
)
|
||||
def debug_logs(
|
||||
log_type: str, session_id: str | None, list_only: bool, lines: int, follow: bool
|
||||
) -> None:
|
||||
"""Show runner, server, or CLI diagnostic logs.
|
||||
|
||||
Prints the tail of the most recent log file for the chosen category.
|
||||
Use ``--list`` to see all available files, or ``--follow`` to stream
|
||||
new output as it is written.
|
||||
|
||||
Pass ``--session SESSION_ID`` (``--type host-runner`` only) to scope
|
||||
output to all log files produced for a specific session across relaunches.
|
||||
|
||||
\b
|
||||
Log locations (relative to ~/.omnigent or $OMNIGENT_DATA_DIR):
|
||||
runner logs/runner/runner-*.log
|
||||
host-runner logs/host-runner/runner-*.log
|
||||
server logs/server/*server*.log
|
||||
cli logs/cli-*.log
|
||||
|
||||
\b
|
||||
Examples:
|
||||
# Tail the most recent local runner log (default)
|
||||
omnigent debug logs
|
||||
# List all local runner log files with sizes
|
||||
omnigent debug logs --list
|
||||
# Show host-runner logs for a specific session (across relaunches)
|
||||
omnigent debug logs --type host-runner --session conv_abc123
|
||||
# List host-runner log files for a session
|
||||
omnigent debug logs --type host-runner --session conv_abc123 --list
|
||||
# Follow the latest server log in real-time
|
||||
omnigent debug logs --type server --follow
|
||||
# Show the full latest CLI diagnostics log
|
||||
omnigent debug logs --type cli -n 0
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from omnigent.host.local_server import _local_data_dir
|
||||
|
||||
if session_id is not None and log_type != "host-runner":
|
||||
raise click.UsageError("--session is only supported with --type host-runner")
|
||||
|
||||
if follow and IS_WINDOWS:
|
||||
raise click.UsageError("--follow is not supported on Windows")
|
||||
|
||||
data_dir = _local_data_dir()
|
||||
|
||||
_log_configs: dict[str, tuple[Path, str]] = {
|
||||
"runner": (data_dir / "logs" / "runner", "runner-*.log"),
|
||||
"host-runner": (data_dir / "logs" / "host-runner", "runner-*.log"),
|
||||
# Covers both server-*.log (omnigent run) and local-server-*.log (daemon).
|
||||
"server": (data_dir / "logs" / "server", "*server*.log"),
|
||||
"cli": (data_dir / "logs", "cli-*.log"),
|
||||
}
|
||||
|
||||
log_dir, pattern = _log_configs[log_type]
|
||||
|
||||
if not log_dir.exists():
|
||||
raise click.ClickException(f"No {log_type} logs found — {log_dir} does not exist.")
|
||||
|
||||
if session_id is not None:
|
||||
# Sanitize the same way connect.py does so the glob matches.
|
||||
slug = re.sub(r"[^\w-]", "", session_id)[:32]
|
||||
pattern = f"runner-{slug}-*.log"
|
||||
|
||||
# Exclude symlinks (e.g. latest-cli.log), sort newest first.
|
||||
log_files = sorted(
|
||||
(f for f in log_dir.glob(pattern) if not f.is_symlink()),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
if not log_files:
|
||||
if session_id is not None:
|
||||
raise click.ClickException(
|
||||
f"No host-runner logs found for session {session_id!r}. "
|
||||
"Session ids appear in filenames only for runners launched "
|
||||
"after this feature was added."
|
||||
)
|
||||
raise click.ClickException(f"No {log_type} log files found in {log_dir}.")
|
||||
|
||||
if list_only:
|
||||
header = (
|
||||
f"host-runner logs for session {session_id!r} in {log_dir}:"
|
||||
if session_id
|
||||
else f"{log_type} logs in {log_dir}:"
|
||||
)
|
||||
click.echo(header)
|
||||
for f in log_files:
|
||||
stat = f.stat()
|
||||
size_kb = stat.st_size / 1024
|
||||
mtime = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(stat.st_mtime))
|
||||
click.echo(f" {mtime} {size_kb:6.1f} KB {f.name}")
|
||||
return
|
||||
|
||||
if follow:
|
||||
# Follow the most recent file only (tail -f can only track one file).
|
||||
latest = log_files[0]
|
||||
click.echo(f"# {latest}", err=True)
|
||||
subprocess.run(["tail", "-f", str(latest)])
|
||||
return
|
||||
|
||||
if session_id is not None:
|
||||
# Show all files for the session, oldest first, with separators.
|
||||
for f in reversed(log_files):
|
||||
click.echo(f"# {f}", err=True)
|
||||
content = f.read_text(errors="replace")
|
||||
if lines > 0:
|
||||
content = "\n".join(content.splitlines()[-lines:])
|
||||
click.echo(content)
|
||||
click.echo()
|
||||
else:
|
||||
latest = log_files[0]
|
||||
click.echo(f"# {latest}", err=True)
|
||||
content = latest.read_text(errors="replace")
|
||||
if lines > 0:
|
||||
content = "\n".join(content.splitlines()[-lines:])
|
||||
click.echo(content)
|
||||
|
||||
|
||||
def _workspace_mount_probe_matches(candidate: str, probe: httpx.Response) -> bool:
|
||||
"""Whether a ``/api/2.0/omnigent`` mount probe answered like omnigent.
|
||||
|
||||
|
||||
+41
-2
@@ -226,6 +226,37 @@ def load_databricks_org_id(server_url: str) -> str | None:
|
||||
DATABRICKS_ORG_ID_HEADER = "X-Databricks-Org-Id"
|
||||
|
||||
|
||||
# Opaque extra request headers for dev/test: a JSON object of header name→value
|
||||
# in :data:`DATABRICKS_EXTRA_HEADERS_ENV_VAR`. Databricks deployments use it to
|
||||
# carry request-routing selector headers so a request pins to a specific server
|
||||
# instance/replica instead of the default one. Folded into
|
||||
# :func:`databricks_request_headers` below so it travels with every
|
||||
# client→server connection built through that one helper — a per-call-site
|
||||
# bearer that skips this helper misses the selectors. Unset in prod.
|
||||
DATABRICKS_EXTRA_HEADERS_ENV_VAR = "OMNIGENT_DATABRICKS_EXTRA_HEADERS"
|
||||
|
||||
|
||||
def _databricks_extra_headers() -> dict[str, str]:
|
||||
"""Return the opaque extra request headers when configured, else ``{}``.
|
||||
|
||||
Reads :data:`DATABRICKS_EXTRA_HEADERS_ENV_VAR`, a JSON object of header
|
||||
name→value. Missing or malformed (unset, not JSON, or not an object) →
|
||||
``{}``, so production and local runs are unaffected.
|
||||
|
||||
:returns: A header dict parsed from the env var, or an empty dict.
|
||||
"""
|
||||
raw = os.environ.get(DATABRICKS_EXTRA_HEADERS_ENV_VAR, "").strip()
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return {}
|
||||
if not isinstance(parsed, dict):
|
||||
return {}
|
||||
return {str(key): str(value) for key, value in parsed.items()}
|
||||
|
||||
|
||||
def databricks_request_headers(
|
||||
server_url: str, *, bearer_token: str | None = None
|
||||
) -> dict[str, str]:
|
||||
@@ -243,12 +274,17 @@ def databricks_request_headers(
|
||||
Both values are omitted when absent, so single-workspace and
|
||||
local-unauthenticated callers get ``{}`` and are unaffected.
|
||||
|
||||
Also folds in any opaque dev/test headers from
|
||||
:data:`DATABRICKS_EXTRA_HEADERS_ENV_VAR` (request-routing selectors set by
|
||||
some Databricks deployments) so every chokepoint that builds headers through
|
||||
this one helper carries them when set.
|
||||
|
||||
:param server_url: The server URL, e.g.
|
||||
``"https://example.databricks.com/api/2.0/omnigent"``.
|
||||
:param bearer_token: The workspace bearer token, or ``None`` when the
|
||||
credential is supplied by a separate mechanism (or there is none).
|
||||
:returns: A header dict carrying ``Authorization`` and/or
|
||||
``X-Databricks-Org-Id`` as available, possibly empty.
|
||||
:returns: A header dict carrying ``Authorization``, ``X-Databricks-Org-Id``,
|
||||
and/or the configured extra headers as available, possibly empty.
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
if bearer_token:
|
||||
@@ -256,6 +292,9 @@ def databricks_request_headers(
|
||||
org_id = load_databricks_org_id(server_url)
|
||||
if org_id:
|
||||
headers[DATABRICKS_ORG_ID_HEADER] = org_id
|
||||
# Opaque dev/test extra headers (request-routing selectors); no-op in prod
|
||||
# (env unset).
|
||||
headers.update(_databricks_extra_headers())
|
||||
return headers
|
||||
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ from omnigent.host.daemon_launch import (
|
||||
wait_for_host_online,
|
||||
wait_for_runner_online,
|
||||
)
|
||||
from omnigent.native_coding_agents import native_shell_terminal_spec
|
||||
from omnigent.native_terminal import (
|
||||
DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S,
|
||||
)
|
||||
@@ -526,21 +527,11 @@ def _materialize_codex_agent_spec(
|
||||
},
|
||||
# Declare a default shell terminal so the relay advertises the
|
||||
# ``sys_terminal_*`` family to the wrapped codex (the relay's
|
||||
# gate is a non-empty ``terminals:`` block on this spec).
|
||||
# Caller process / no sandbox matches the ``os_env`` stance
|
||||
# above — the native CLI already runs unsandboxed on the
|
||||
# user's workspace.
|
||||
"terminals": {
|
||||
"shell": {
|
||||
"command": "bash",
|
||||
"allow_cwd_override": True,
|
||||
"os_env": {
|
||||
"type": "caller_process",
|
||||
"cwd": ".",
|
||||
"sandbox": {"type": "none"},
|
||||
},
|
||||
},
|
||||
},
|
||||
# gate is a non-empty ``terminals:`` block on this spec). Its
|
||||
# command follows the user's ``$SHELL`` (zsh/fish/bash); caller
|
||||
# process / no sandbox matches the ``os_env`` stance above — the
|
||||
# native CLI already runs unsandboxed on the user's workspace.
|
||||
"terminals": native_shell_terminal_spec(),
|
||||
}
|
||||
yaml_path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8")
|
||||
return yaml_path
|
||||
|
||||
@@ -36,7 +36,6 @@ from omnigent.inner.codex_executor import (
|
||||
_clean_codex_env,
|
||||
_codex_cli_version,
|
||||
_codex_home_config_source_from_env,
|
||||
_create_subprocess_exec,
|
||||
_databricks_codex_auth_command,
|
||||
_databricks_codex_base_url,
|
||||
_databricks_codex_config_overrides,
|
||||
@@ -88,84 +87,6 @@ _TRUSTED_HOOK_STATUSES = frozenset({"trusted", "managed"})
|
||||
# warning rather than crash startup on an un-trustable hook.
|
||||
_MIN_POLICY_HOOK_CODEX_VERSION = (0, 129, 0)
|
||||
|
||||
# Opt-in flag for the explicit ``--model`` launch flag. Off by default: the
|
||||
# per-session ``config.toml`` ``model =`` pin (``_pin_codex_config_model``)
|
||||
# already routes the override today, so the explicit flag is a parallel,
|
||||
# additive path the operator turns on per deployment. Truthy values mirror
|
||||
# the ``_TRUE_VALUES`` convention used across the codebase
|
||||
# (``omnigent/_startup_profile.py``, ``omnigent/cli.py``).
|
||||
_MODEL_FLAG_ENV_VAR = "OMNIGENT_CODEX_NATIVE_MODEL_FLAG"
|
||||
_MODEL_FLAG_TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
|
||||
# Timeout for the one-shot ``codex --help`` capability probe. Matches the
|
||||
# ``codex --version`` probe budget -- a hung help invocation must never block
|
||||
# app-server startup.
|
||||
_CODEX_HELP_PROBE_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
def _model_flag_enabled(env: dict[str, str] | None = None) -> bool:
|
||||
"""
|
||||
Return whether the explicit ``--model`` launch flag is opted in.
|
||||
|
||||
The flag is parallel to the always-on ``config.toml`` model pin, so it
|
||||
defaults OFF: a deployment enables it by setting
|
||||
:data:`_MODEL_FLAG_ENV_VAR` to a truthy value.
|
||||
|
||||
:param env: Environment mapping to inspect; defaults to ``os.environ``.
|
||||
:returns: ``True`` when the override should also be passed as an
|
||||
explicit ``--model`` launch flag.
|
||||
"""
|
||||
source = os.environ if env is None else env
|
||||
return source.get(_MODEL_FLAG_ENV_VAR, "").strip().lower() in _MODEL_FLAG_TRUE_VALUES
|
||||
|
||||
|
||||
async def _codex_supports_model_flag(codex_path: str) -> bool:
|
||||
"""
|
||||
Detect whether the codex CLI accepts a global ``--model`` flag.
|
||||
|
||||
Runs ``codex --help`` and looks for the ``--model`` long option in the
|
||||
top-level options. Codex exposes ``-m/--model`` as a global flag that
|
||||
precedes the ``app-server`` subcommand; builds that predate it omit the
|
||||
option from ``--help``, so the caller skips the flag (passing an unknown
|
||||
flag would error) and relies on the always-on ``config.toml`` pin.
|
||||
|
||||
:param codex_path: Path to the codex CLI, e.g.
|
||||
``"/usr/local/bin/codex"``.
|
||||
:returns: ``True`` when ``--model`` appears in ``codex --help`` output;
|
||||
``False`` when it does not, or the probe cannot be run / times out
|
||||
(treated conservatively as "unsupported" so the flag is not passed).
|
||||
"""
|
||||
try:
|
||||
proc = await _create_subprocess_exec(
|
||||
codex_path,
|
||||
"--help",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
stdout, _ = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=_CODEX_HELP_PROBE_TIMEOUT_SECONDS
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# A hung ``codex --help`` must not block startup: kill it and treat
|
||||
# the flag as unsupported (the config.toml pin still carries the model).
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await proc.wait()
|
||||
return False
|
||||
# Match ``--model`` only as an option *definition* line, not anywhere the
|
||||
# word appears in help prose. Clap renders options as an indented line
|
||||
# whose first token is the option, e.g. `` -m, --model <MODEL>`` (or a
|
||||
# long-only `` --model <MODEL>``). Anchor to the start of such a line
|
||||
# — optional indent, an optional short alias (``-m, ``), then ``--model``
|
||||
# at an option boundary. This rejects lookalikes (``--model-provider``)
|
||||
# and descriptions that merely mention ``--model`` mid-sentence, either of
|
||||
# which would otherwise pass an unsupported flag to the launch.
|
||||
help_text = stdout.decode("utf-8", errors="replace")
|
||||
return re.search(r"^\s*(?:-\S+,\s+)?--model(?=[\s=<]|$)", help_text, re.MULTILINE) is not None
|
||||
|
||||
|
||||
def _format_codex_version(version: tuple[int, int, int] | None) -> str:
|
||||
"""
|
||||
@@ -649,30 +570,6 @@ class CodexNativeAppServer:
|
||||
)
|
||||
reconcile_codex_native_process_registry()
|
||||
resolved_listen = self.listen_url or f"unix://{self.socket_path}"
|
||||
proc_env = {**self.env, "CODEX_HOME": str(self.codex_home)}
|
||||
# Opt-in, additive to the config.toml ``model =`` pin above: when the
|
||||
# operator enables the flag and a model is pinned, ALSO pass it
|
||||
# explicitly. ``-m/--model`` is a codex *global* option, so it must
|
||||
# precede the ``app-server`` subcommand. A codex build that lacks the
|
||||
# flag simply doesn't get it (passing an unknown flag would error) --
|
||||
# the config.toml pin remains the primary route, so the session still
|
||||
# launches on the right model regardless.
|
||||
# Read the opt-in from the omnigent server's OWN process environment
|
||||
# (``os.environ``, the default), NOT ``self.env``: ``self.env`` is the
|
||||
# cleaned codex spawn env from ``_clean_codex_env``, whose prefix
|
||||
# allowlist strips ``OMNIGENT_*`` keys -- so the flag would never be
|
||||
# visible there. The flag is an operator knob for omnigent, not
|
||||
# something codex itself consumes.
|
||||
model_global_args: list[str] = []
|
||||
if (
|
||||
self.pinned_model
|
||||
and _model_flag_enabled()
|
||||
and await _codex_supports_model_flag(self.codex_path)
|
||||
):
|
||||
model_global_args = ["--model", self.pinned_model]
|
||||
# argv[0] carries the inert crash-reap marker (the real binary is passed
|
||||
# via ``executable=`` below); the model global option rides after it so
|
||||
# codex still parses it ahead of the ``app-server`` subcommand.
|
||||
self.process_registry_tag = f"codex-native-{uuid.uuid4().hex}"
|
||||
tagged_argv0 = (
|
||||
f"{Path(self.codex_path).name} "
|
||||
@@ -680,22 +577,16 @@ class CodexNativeAppServer:
|
||||
)
|
||||
argv = [
|
||||
tagged_argv0,
|
||||
*model_global_args,
|
||||
"app-server",
|
||||
"--listen",
|
||||
resolved_listen,
|
||||
]
|
||||
for override in self.config_overrides:
|
||||
argv.extend(["-c", override])
|
||||
proc_env = {**self.env, "CODEX_HOME": str(self.codex_home)}
|
||||
self.process_owner_lock = acquire_codex_native_process_owner_lock()
|
||||
try:
|
||||
# Spawn through the module-level ``_create_subprocess_exec``
|
||||
# indirection (a transparent passthrough to
|
||||
# ``asyncio.create_subprocess_exec``) so tests can stub the spawn
|
||||
# by patching that name — patching ``…app_server.asyncio.\
|
||||
# create_subprocess_exec`` would walk into the real asyncio
|
||||
# singleton and leak the mock across the process.
|
||||
self.proc = await _create_subprocess_exec(
|
||||
self.proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
|
||||
@@ -19,6 +19,21 @@ CODEX_NATIVE_REQUEST_SESSION_ID_ENV_VAR = "HARNESS_CODEX_NATIVE_REQUEST_SESSION_
|
||||
|
||||
_STATE_FILE = "state.json"
|
||||
_STARTUP_ERROR_FILE = "startup_error.json"
|
||||
# Per-MCP-server startup state mirrored from Codex's
|
||||
# ``mcpServer/startupStatus/updated`` notifications. Written by the
|
||||
# forwarder (and by ``wait_for_thread_started`` while it drains startup
|
||||
# events), read by the executor's first-turn gate and the runner's
|
||||
# Stop handler.
|
||||
_MCP_STARTUP_FILE = "mcp_startup.json"
|
||||
|
||||
# Startup states mirrored from Codex's ``McpServerStartupState`` enum.
|
||||
MCP_STARTUP_STARTING = "starting"
|
||||
MCP_STARTUP_READY = "ready"
|
||||
MCP_STARTUP_FAILED = "failed"
|
||||
MCP_STARTUP_CANCELLED = "cancelled"
|
||||
MCP_STARTUP_STATES = frozenset(
|
||||
{MCP_STARTUP_STARTING, MCP_STARTUP_READY, MCP_STARTUP_FAILED, MCP_STARTUP_CANCELLED}
|
||||
)
|
||||
# Must match ``_CONFIG_FILE`` in ``claude_native_bridge.py`` because
|
||||
# ``serve-mcp`` reads this filename for the token.
|
||||
_MCP_CONFIG_FILE = "bridge.json"
|
||||
@@ -341,7 +356,7 @@ def clear_bridge_state(bridge_dir: Path) -> None:
|
||||
:param bridge_dir: Native Codex bridge directory.
|
||||
:returns: None.
|
||||
"""
|
||||
for name in (_STATE_FILE, _STARTUP_ERROR_FILE):
|
||||
for name in (_STATE_FILE, _STARTUP_ERROR_FILE, _MCP_STARTUP_FILE):
|
||||
try:
|
||||
(bridge_dir / name).unlink()
|
||||
except FileNotFoundError:
|
||||
@@ -392,6 +407,166 @@ def read_bridge_startup_error(bridge_dir: Path) -> str | None:
|
||||
return message if isinstance(message, str) and message else None
|
||||
|
||||
|
||||
def read_mcp_startup(bridge_dir: Path) -> dict[str, dict[str, str | None]]:
|
||||
"""
|
||||
Read the recorded per-MCP-server startup state.
|
||||
|
||||
:param bridge_dir: Native Codex bridge directory.
|
||||
:returns: Mapping of server name to its latest startup record, e.g.
|
||||
``{"safe": {"status": "starting", "error": None}}``. Empty when
|
||||
no state has been recorded or the file is unreadable.
|
||||
"""
|
||||
path = bridge_dir / _MCP_STARTUP_FILE
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
servers = raw.get("servers") if isinstance(raw, dict) else None
|
||||
if not isinstance(servers, dict):
|
||||
return {}
|
||||
parsed: dict[str, dict[str, str | None]] = {}
|
||||
for name, record in servers.items():
|
||||
if not (isinstance(name, str) and name and isinstance(record, dict)):
|
||||
continue
|
||||
status = record.get("status")
|
||||
if status not in MCP_STARTUP_STATES:
|
||||
continue
|
||||
error = record.get("error")
|
||||
parsed[name] = {
|
||||
"status": status,
|
||||
"error": error if isinstance(error, str) and error else None,
|
||||
}
|
||||
return parsed
|
||||
|
||||
|
||||
def _write_mcp_startup(bridge_dir: Path, servers: dict[str, dict[str, str | None]]) -> None:
|
||||
"""
|
||||
Persist the per-MCP-server startup map atomically (best-effort).
|
||||
|
||||
:param bridge_dir: Native Codex bridge directory.
|
||||
:param servers: Full startup map, e.g.
|
||||
``{"safe": {"status": "ready", "error": None}}``.
|
||||
:returns: None.
|
||||
"""
|
||||
try:
|
||||
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
path = bridge_dir / _MCP_STARTUP_FILE
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=f"{_MCP_STARTUP_FILE}.", dir=str(bridge_dir))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump({"servers": servers}, handle, sort_keys=True)
|
||||
handle.write("\n")
|
||||
os.replace(tmp_name, path)
|
||||
finally:
|
||||
if os.path.exists(tmp_name):
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
return # best-effort; surfacing MCP state must never sink startup
|
||||
|
||||
|
||||
def update_mcp_server_startup(
|
||||
bridge_dir: Path,
|
||||
name: str,
|
||||
status: str,
|
||||
error: str | None = None,
|
||||
) -> dict[str, dict[str, str | None]]:
|
||||
"""
|
||||
Record one Codex MCP-server startup update.
|
||||
|
||||
:param bridge_dir: Native Codex bridge directory.
|
||||
:param name: MCP server name, e.g. ``"storage-console"``.
|
||||
:param status: One of :data:`MCP_STARTUP_STATES`.
|
||||
:param error: Failure detail when ``status == "failed"``, e.g.
|
||||
``"handshaking with MCP server failed"``. ``None`` otherwise.
|
||||
:returns: The full startup map after the update.
|
||||
"""
|
||||
servers = read_mcp_startup(bridge_dir)
|
||||
servers[name] = {"status": status, "error": error}
|
||||
_write_mcp_startup(bridge_dir, servers)
|
||||
return servers
|
||||
|
||||
|
||||
def pending_mcp_servers(servers: dict[str, dict[str, str | None]]) -> list[str]:
|
||||
"""
|
||||
Return the MCP servers still reported as ``starting``.
|
||||
|
||||
:param servers: Startup map from :func:`read_mcp_startup`.
|
||||
:returns: Sorted server names whose latest status is ``starting``.
|
||||
"""
|
||||
return sorted(
|
||||
name for name, record in servers.items() if record.get("status") == MCP_STARTUP_STARTING
|
||||
)
|
||||
|
||||
|
||||
def cancel_pending_mcp_startup(bridge_dir: Path) -> list[str]:
|
||||
"""
|
||||
Mark every still-``starting`` MCP server as ``cancelled``.
|
||||
|
||||
Used by the Stop path so the executor's first-turn gate unblocks
|
||||
immediately, even when Codex's own ``cancelled`` notifications are
|
||||
delayed or lost.
|
||||
|
||||
:param bridge_dir: Native Codex bridge directory.
|
||||
:returns: Sorted names of the servers that were flipped, e.g.
|
||||
``["storage-console"]``. Empty when nothing was pending.
|
||||
"""
|
||||
servers = read_mcp_startup(bridge_dir)
|
||||
pending = pending_mcp_servers(servers)
|
||||
if not pending:
|
||||
return []
|
||||
for name in pending:
|
||||
servers[name] = {"status": MCP_STARTUP_CANCELLED, "error": servers[name].get("error")}
|
||||
_write_mcp_startup(bridge_dir, servers)
|
||||
return pending
|
||||
|
||||
|
||||
def settle_pending_mcp_startup(bridge_dir: Path) -> tuple[dict[str, dict[str, str | None]], bool]:
|
||||
"""
|
||||
Drop every still-``starting`` MCP server from the recorded map.
|
||||
|
||||
Codex delivers per-server terminal states (ready/failed) only to the
|
||||
connection that owns the thread — never to Omnigent's observer
|
||||
connection — so when a settle signal arrives (the thread went idle
|
||||
after a turn, or the startup window elapsed) the round is known to be
|
||||
over but the per-server outcomes are not. Unresolved entries are
|
||||
removed rather than guessed; locally-known terminal states
|
||||
(``cancelled`` from a Stop) are preserved.
|
||||
|
||||
:param bridge_dir: Native Codex bridge directory.
|
||||
:returns: ``(map_after, changed)`` — the settled map and whether any
|
||||
entry was dropped.
|
||||
"""
|
||||
# The read→write below is not locked across processes: a runner Stop
|
||||
# can flip an entry to ``cancelled`` in between, and this write drops
|
||||
# it. Cosmetic only — both outcomes end the round, and the Stop path
|
||||
# publishes its cancelled map independently.
|
||||
servers = read_mcp_startup(bridge_dir)
|
||||
pending = pending_mcp_servers(servers)
|
||||
if not pending:
|
||||
return servers, False
|
||||
for name in pending:
|
||||
servers.pop(name, None)
|
||||
_write_mcp_startup(bridge_dir, servers)
|
||||
return servers, True
|
||||
|
||||
|
||||
def mcp_startup_waiting_detail(servers: dict[str, dict[str, str | None]]) -> str | None:
|
||||
"""
|
||||
Describe the MCP servers a startup wait is still blocked on.
|
||||
|
||||
:param servers: Startup map from :func:`read_mcp_startup`.
|
||||
:returns: Text naming the pending servers, e.g.
|
||||
``"MCP startup still waiting on storage-console"``, or ``None``
|
||||
when nothing is pending.
|
||||
"""
|
||||
pending = pending_mcp_servers(servers)
|
||||
if not pending:
|
||||
return None
|
||||
return f"MCP startup still waiting on {', '.join(pending)}"
|
||||
|
||||
|
||||
def read_bridge_state(bridge_dir: Path) -> CodexNativeBridgeState | None:
|
||||
"""
|
||||
Read shared native Codex bridge state.
|
||||
|
||||
@@ -34,12 +34,18 @@ from omnigent.codex_native_app_server import (
|
||||
)
|
||||
from omnigent.codex_native_bridge import (
|
||||
CODEX_NATIVE_BRIDGE_ID_LABEL_KEY,
|
||||
MCP_STARTUP_STARTING,
|
||||
MCP_STARTUP_STATES,
|
||||
CodexNativeBridgeState,
|
||||
clear_active_turn_id_if_matches,
|
||||
codex_home_for_bridge_dir,
|
||||
pending_mcp_servers,
|
||||
read_bridge_state,
|
||||
read_codex_config_model,
|
||||
read_mcp_startup,
|
||||
settle_pending_mcp_startup,
|
||||
update_active_turn_id,
|
||||
update_mcp_server_startup,
|
||||
update_thread_id,
|
||||
write_bridge_state,
|
||||
)
|
||||
@@ -112,6 +118,31 @@ _CODEX_ELICITATION_CONNECT_TIMEOUT_SECONDS = 30.0
|
||||
_CODEX_ELICITATION_RETRY_INITIAL_BACKOFF_SECONDS = 1.0
|
||||
_CODEX_ELICITATION_RETRY_MAX_BACKOFF_SECONDS = 30.0
|
||||
_CODEX_MCP_ELICITATION_REQUEST_METHOD = "mcpServer/elicitation/request"
|
||||
# Per-server MCP startup progress (issue #2058). Codex runs an MCP
|
||||
# startup round when a thread starts, but delivers the per-server
|
||||
# ``mcpServer/startupStatus/updated`` edges ONLY to the connection that
|
||||
# owns the thread (the TUI) — verified against codex 0.142.5 — so this
|
||||
# observer connection cannot passively mirror them. Instead the round is
|
||||
# SYNTHESIZED: at forwarder start the config-declared servers are
|
||||
# recorded as ``starting`` (true — codex boots them all at thread start)
|
||||
# in the bridge dir and posted to Omnigent as ``external_mcp_startup``; the
|
||||
# round is settled (unresolved entries dropped) when the thread goes
|
||||
# idle after a turn — codex defers turn execution until startup ends, so
|
||||
# an idle edge proves the round is over — or when the config-derived
|
||||
# startup window elapses. ``cancelled`` states are recorded locally by
|
||||
# the Stop path. The notification handler is kept as a zero-cost path
|
||||
# for any delivery codex broadens later (it fully supersedes synthesis
|
||||
# when edges do arrive).
|
||||
_CODEX_MCP_STARTUP_STATUS_METHOD = "mcpServer/startupStatus/updated"
|
||||
_CODEX_THREAD_STATUS_CHANGED_METHOD = "thread/status/changed"
|
||||
_EXTERNAL_MCP_STARTUP_TYPE = "external_mcp_startup"
|
||||
# Codex bounds each MCP server's spawn+handshake by its per-server
|
||||
# ``startup_timeout_sec`` (codex default 10s); the round cannot outlive
|
||||
# the slowest server's budget. The synthesis settle timer mirrors that
|
||||
# bound, with floor/grace/cap keeping a misconfigured value sane.
|
||||
_MCP_STARTUP_DEFAULT_TIMEOUT_SECONDS = 10.0
|
||||
_MCP_STARTUP_SETTLE_GRACE_SECONDS = 15.0
|
||||
_MCP_STARTUP_SETTLE_MAX_SECONDS = 240.0
|
||||
_CODEX_TOOL_REQUEST_USER_INPUT_METHOD = "item/tool/requestUserInput"
|
||||
_CODEX_COMMAND_EXECUTION_REQUEST_APPROVAL_METHOD = "item/commandExecution/requestApproval"
|
||||
_CODEX_FILE_CHANGE_REQUEST_APPROVAL_METHOD = "item/fileChange/requestApproval"
|
||||
@@ -1601,6 +1632,14 @@ async def supervise_forwarder(
|
||||
# outage or restart). Runs before live forwarding begins, so no
|
||||
# other writer races the dead-letter files (#1579).
|
||||
await _replay_dead_letters_on_startup(ap_client, bridge_dir)
|
||||
# Synthesize the thread's MCP startup round (see the comment on
|
||||
# _CODEX_MCP_STARTUP_STATUS_METHOD): the fresh-launch forwarder
|
||||
# starts right at thread creation, which is when codex boots its
|
||||
# configured MCP servers. Skipped when the bridge already carries
|
||||
# round state (forwarder reconnect mid-session).
|
||||
mcp_settle_timer = await _seed_mcp_startup_round(
|
||||
ap_client, session_id=session_id, bridge_dir=bridge_dir
|
||||
)
|
||||
target = _ForwarderTarget(
|
||||
session_id=session_id,
|
||||
thread_id=thread_id,
|
||||
@@ -1686,6 +1725,10 @@ async def supervise_forwarder(
|
||||
except Exception: # noqa: BLE001 - keep the long-lived mirror alive.
|
||||
_logger.warning("Codex forwarder event handling failed", exc_info=True)
|
||||
finally:
|
||||
if mcp_settle_timer is not None:
|
||||
mcp_settle_timer.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await mcp_settle_timer
|
||||
await target.delta_coalescer.close()
|
||||
await target.usage_coalescer.close()
|
||||
await target.elicitation_tracker.close()
|
||||
@@ -2254,6 +2297,39 @@ async def _handle_event(
|
||||
_parent_thread_id_from_started_event(event),
|
||||
)
|
||||
return
|
||||
if method == _CODEX_MCP_STARTUP_STATUS_METHOD:
|
||||
# MCP startup is bridge-level state, surfaced on the parent
|
||||
# session. The notification's ``threadId`` is nullable; a child
|
||||
# thread's startup (different id) is not mirrored.
|
||||
event_thread_id = _thread_id_from_params(params)
|
||||
if (
|
||||
event_thread_id is None
|
||||
or expected_thread_id is None
|
||||
or event_thread_id == expected_thread_id
|
||||
):
|
||||
parent_session_id = (
|
||||
forwarder_state.parent_session_id
|
||||
if forwarder_state is not None and forwarder_state.parent_session_id is not None
|
||||
else session_id
|
||||
)
|
||||
await _handle_mcp_startup_status(
|
||||
client,
|
||||
session_id=parent_session_id,
|
||||
bridge_dir=bridge_dir,
|
||||
params=params,
|
||||
)
|
||||
return
|
||||
if _is_thread_idle_status_event(method, params) and _thread_id_from_params(params) in {
|
||||
None,
|
||||
expected_thread_id,
|
||||
}:
|
||||
# A completed turn proves MCP startup settled (codex defers turn
|
||||
# execution until the round ends) — resolve the synthesized round.
|
||||
# Not an exclusive handler: idle status also feeds the subscribe
|
||||
# release below, so fall through.
|
||||
await _settle_mcp_startup(
|
||||
client, session_id=session_id, bridge_dir=bridge_dir, reason="thread went idle"
|
||||
)
|
||||
# Resolve routing: parent thread, known child thread, or stale/ignored.
|
||||
route_session_id, is_child = _resolve_event_session(
|
||||
params, method, expected_thread_id, forwarder_state, fallback_session_id=session_id
|
||||
@@ -2989,6 +3065,256 @@ def _handle_turn_diff_updated(
|
||||
forwarder_state.note_turn_diff(turn_id, diff if isinstance(diff, str) else "")
|
||||
|
||||
|
||||
async def _handle_mcp_startup_status(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
bridge_dir: Path,
|
||||
params: dict[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
Mirror one Codex MCP-server startup update.
|
||||
|
||||
Records the update into the bridge dir (the Stop path and turn-error
|
||||
text read it) and republishes the full per-server map to Omnigent so the
|
||||
web session shows startup progress. In practice codex delivers these
|
||||
edges only to the thread-owning connection (see the comment on
|
||||
:data:`_CODEX_MCP_STARTUP_STATUS_METHOD`); when they do arrive they
|
||||
carry real terminal states and supersede the synthesized round.
|
||||
|
||||
:param client: HTTP client for Omnigent event posts.
|
||||
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
|
||||
:param bridge_dir: Native Codex bridge directory.
|
||||
:param params: Codex ``mcpServer/startupStatus/updated`` params, e.g.
|
||||
``{"name": "safe", "status": "failed", "error": "..."}``.
|
||||
:returns: None.
|
||||
"""
|
||||
name = params.get("name")
|
||||
status = params.get("status")
|
||||
if not (isinstance(name, str) and name and status in MCP_STARTUP_STATES):
|
||||
_logger.info("Codex forwarder ignored malformed MCP startup update: %r", params)
|
||||
return
|
||||
error = params.get("error")
|
||||
servers = update_mcp_server_startup(
|
||||
bridge_dir,
|
||||
name,
|
||||
status,
|
||||
error=error if isinstance(error, str) and error else None,
|
||||
)
|
||||
await _post_mcp_startup(client, session_id, servers)
|
||||
|
||||
|
||||
def _expected_mcp_servers_from_config(bridge_dir: Path) -> list[str]:
|
||||
"""
|
||||
Read the enabled MCP server names from the session's Codex config.
|
||||
|
||||
The per-session ``config.toml`` (private ``CODEX_HOME``) is what the
|
||||
app-server loads, so its ``[mcp_servers.*]`` tables are exactly the
|
||||
servers codex boots at thread start — including the injected
|
||||
``omnigent`` relay server. Codex-internal servers that are not
|
||||
config-declared (e.g. ``codex_apps``) are not visible here and are
|
||||
simply absent from the synthesized round.
|
||||
|
||||
:param bridge_dir: Native Codex bridge directory.
|
||||
:returns: Sorted enabled server names, e.g. ``["omnigent", "safe"]``.
|
||||
Empty when the config is missing or unparsable.
|
||||
"""
|
||||
import tomllib
|
||||
|
||||
config_path = codex_home_for_bridge_dir(bridge_dir) / "config.toml"
|
||||
try:
|
||||
config = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
return []
|
||||
servers = config.get("mcp_servers")
|
||||
if not isinstance(servers, dict):
|
||||
return []
|
||||
return sorted(
|
||||
name
|
||||
for name, table in servers.items()
|
||||
if isinstance(name, str)
|
||||
and name
|
||||
and isinstance(table, dict)
|
||||
and table.get("enabled") is not False
|
||||
)
|
||||
|
||||
|
||||
def _mcp_startup_settle_timeout_seconds(bridge_dir: Path) -> float:
|
||||
"""
|
||||
Derive the synthesized round's settle window from the session config.
|
||||
|
||||
Codex bounds each server's spawn+handshake by its per-server
|
||||
``startup_timeout_sec`` (default
|
||||
:data:`_MCP_STARTUP_DEFAULT_TIMEOUT_SECONDS`), so the round cannot
|
||||
outlive the slowest server's budget; a grace period absorbs spawn
|
||||
overhead and the cap keeps a misconfigured budget from pinning the
|
||||
band for many minutes.
|
||||
|
||||
:param bridge_dir: Native Codex bridge directory.
|
||||
:returns: Settle timeout in seconds, e.g. ``135.0`` for a config whose
|
||||
slowest server declares ``startup_timeout_sec = 120``.
|
||||
"""
|
||||
import tomllib
|
||||
|
||||
slowest = _MCP_STARTUP_DEFAULT_TIMEOUT_SECONDS
|
||||
config_path = codex_home_for_bridge_dir(bridge_dir) / "config.toml"
|
||||
try:
|
||||
config = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
config = {}
|
||||
servers = config.get("mcp_servers")
|
||||
if isinstance(servers, dict):
|
||||
for table in servers.values():
|
||||
# Same enabled filter as _expected_mcp_servers_from_config:
|
||||
# codex never boots a disabled server, so its budget must not
|
||||
# stretch the window for a round it is not part of.
|
||||
if not isinstance(table, dict) or table.get("enabled") is False:
|
||||
continue
|
||||
timeout = table.get("startup_timeout_sec")
|
||||
if isinstance(timeout, (int, float)) and timeout > slowest:
|
||||
slowest = float(timeout)
|
||||
return min(slowest + _MCP_STARTUP_SETTLE_GRACE_SECONDS, _MCP_STARTUP_SETTLE_MAX_SECONDS)
|
||||
|
||||
|
||||
def _arm_mcp_settle_timer(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
bridge_dir: Path,
|
||||
) -> asyncio.Task[None]:
|
||||
"""
|
||||
Arm the bounded settle window for an in-flight MCP startup round.
|
||||
|
||||
:param client: HTTP client for Omnigent event posts.
|
||||
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
|
||||
:param bridge_dir: Native Codex bridge directory.
|
||||
:returns: The settle-timer task.
|
||||
"""
|
||||
timeout = _mcp_startup_settle_timeout_seconds(bridge_dir)
|
||||
|
||||
async def settle_after_window() -> None:
|
||||
"""Settle the synthesized round once the startup window elapses."""
|
||||
await _sleep(timeout)
|
||||
await _settle_mcp_startup(
|
||||
client, session_id=session_id, bridge_dir=bridge_dir, reason="startup window elapsed"
|
||||
)
|
||||
|
||||
return asyncio.create_task(settle_after_window(), name="codex-native-mcp-settle")
|
||||
|
||||
|
||||
async def _seed_mcp_startup_round(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
bridge_dir: Path,
|
||||
) -> asyncio.Task[None] | None:
|
||||
"""
|
||||
Record the config-declared MCP servers as ``starting`` and post them.
|
||||
|
||||
Seeds once per app-server launch: ``clear_bridge_state`` wipes the
|
||||
recorded map before each launch, and an existing map means a
|
||||
forwarder reconnect mid-session — reseeding then would flash a false
|
||||
"starting" band for servers that finished booting long ago. A
|
||||
reconnect that finds the round still pending does re-arm the settle
|
||||
window, though: the previous forwarder's timer died with it, and
|
||||
without a replacement a missed idle edge would leave the band stuck
|
||||
on "starting" for the rest of the session.
|
||||
|
||||
:param client: HTTP client for Omnigent event posts.
|
||||
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
|
||||
:param bridge_dir: Native Codex bridge directory.
|
||||
:returns: The armed settle-timer task, or ``None`` when the recorded
|
||||
round has already settled.
|
||||
"""
|
||||
existing = read_mcp_startup(bridge_dir)
|
||||
if existing:
|
||||
if not pending_mcp_servers(existing):
|
||||
return None
|
||||
_logger.info("Codex MCP startup round still pending after reconnect; re-arming settle")
|
||||
return _arm_mcp_settle_timer(client, session_id=session_id, bridge_dir=bridge_dir)
|
||||
expected = _expected_mcp_servers_from_config(bridge_dir)
|
||||
if not expected:
|
||||
return None
|
||||
servers: dict[str, dict[str, str | None]] = {}
|
||||
for name in expected:
|
||||
servers = update_mcp_server_startup(bridge_dir, name, MCP_STARTUP_STARTING)
|
||||
_logger.info("Codex MCP startup round synthesized: %s", ", ".join(expected))
|
||||
await _post_mcp_startup(client, session_id, servers)
|
||||
return _arm_mcp_settle_timer(client, session_id=session_id, bridge_dir=bridge_dir)
|
||||
|
||||
|
||||
async def _settle_mcp_startup(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
bridge_dir: Path,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""
|
||||
Settle the synthesized MCP startup round, if any of it is unresolved.
|
||||
|
||||
Drops still-``starting`` entries from the bridge map (their real
|
||||
terminal states are only ever delivered to the thread-owning
|
||||
connection) and posts the settled map so the web band clears.
|
||||
Locally-recorded terminal states — ``cancelled`` from a Stop — are
|
||||
preserved. Idempotent: a fully settled map is left untouched.
|
||||
|
||||
:param client: HTTP client for Omnigent event posts.
|
||||
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
|
||||
:param bridge_dir: Native Codex bridge directory.
|
||||
:param reason: Settle trigger for logs, e.g. ``"thread went idle"``.
|
||||
:returns: None.
|
||||
"""
|
||||
servers, changed = settle_pending_mcp_startup(bridge_dir)
|
||||
if not changed:
|
||||
return
|
||||
_logger.info("Codex MCP startup round settled (%s)", reason)
|
||||
await _post_mcp_startup(client, session_id, servers)
|
||||
|
||||
|
||||
def _is_thread_idle_status_event(method: str, params: dict[str, Any]) -> bool:
|
||||
"""
|
||||
Return whether an event reports the thread going idle.
|
||||
|
||||
Codex defers turn execution until MCP startup settles, so a thread
|
||||
reaching ``idle`` after a turn proves the startup round is over. This
|
||||
is one of the few notifications codex broadcasts to non-owning
|
||||
connections, making it the natural live settle signal for the
|
||||
synthesized round.
|
||||
|
||||
:param method: Codex method value, e.g. ``"thread/status/changed"``.
|
||||
:param params: Codex notification params.
|
||||
:returns: ``True`` for an idle ``thread/status/changed``.
|
||||
"""
|
||||
if method != _CODEX_THREAD_STATUS_CHANGED_METHOD:
|
||||
return False
|
||||
status = params.get("status")
|
||||
return isinstance(status, dict) and status.get("type") == "idle"
|
||||
|
||||
|
||||
async def _post_mcp_startup(
|
||||
client: httpx.AsyncClient,
|
||||
session_id: str,
|
||||
servers: dict[str, dict[str, str | None]],
|
||||
) -> None:
|
||||
"""
|
||||
Post the current per-MCP-server startup map to Omnigent.
|
||||
|
||||
:param client: HTTP client for Omnigent event posts.
|
||||
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
|
||||
:param servers: Full startup map, e.g.
|
||||
``{"safe": {"status": "starting", "error": None}}``.
|
||||
:returns: None.
|
||||
"""
|
||||
response = await _post_session_event(
|
||||
client,
|
||||
session_id,
|
||||
event_type=_EXTERNAL_MCP_STARTUP_TYPE,
|
||||
data={"servers": servers},
|
||||
)
|
||||
_log_failed_session_event_post(_EXTERNAL_MCP_STARTUP_TYPE, response)
|
||||
|
||||
|
||||
def _is_codex_elicitation_request(event: CodexMessage) -> bool:
|
||||
"""
|
||||
Return whether an app-server frame asks this client for input.
|
||||
|
||||
@@ -152,25 +152,27 @@ def _main_evaluate_policy(argv: list[str]) -> int:
|
||||
# The session is governed (bridge state + ap_server_url) and we have a
|
||||
# policy-relevant event: from here a failure to obtain a usable verdict
|
||||
# fails CLOSED for the tool-call gate (see ``fail_closed_hook_output``).
|
||||
def _fail_closed() -> int:
|
||||
out = fail_closed_hook_output(hook_event)
|
||||
reauth = policy_hook_reauth(ap_server_url, headers)
|
||||
|
||||
def _fail_closed(detail: str | None = None) -> int:
|
||||
out = fail_closed_hook_output(hook_event, detail)
|
||||
if out is not None:
|
||||
sys.stdout.write(json.dumps(out))
|
||||
return 0
|
||||
|
||||
session_component = urllib.parse.quote(session_id, safe="")
|
||||
url = f"{ap_server_url.rstrip('/')}/v1/sessions/{session_component}/policies/evaluate"
|
||||
resp = post_evaluate_with_retry(
|
||||
resp, api_error = post_evaluate_with_retry(
|
||||
url,
|
||||
headers,
|
||||
eval_request,
|
||||
_EVALUATE_POLICY_TIMEOUT_S,
|
||||
"codex evaluate-policy hook",
|
||||
# Re-mint the baked one-shot token if it lapses mid-session.
|
||||
reauth=policy_hook_reauth(ap_server_url, headers),
|
||||
reauth=reauth,
|
||||
)
|
||||
if resp is None:
|
||||
return _fail_closed()
|
||||
return _fail_closed(api_error or reauth.failure_reason)
|
||||
if not resp.content:
|
||||
print("omnigent codex evaluate-policy hook: empty Omnigent response", file=sys.stderr)
|
||||
return _fail_closed()
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Read Omnigent's user and project configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
_CONFIG_HOME_ENV_VAR = "OMNIGENT_CONFIG_HOME"
|
||||
_GLOBAL_CONFIG_PATH = Path.home() / ".omnigent" / "config.yaml"
|
||||
_LOCAL_CONFIG_RELPATH = Path(".omnigent") / "config.yaml"
|
||||
|
||||
|
||||
def global_config_path(default_path: Path | None = None) -> Path:
|
||||
"""Return the effective user-level config path."""
|
||||
if config_home := os.environ.get(_CONFIG_HOME_ENV_VAR):
|
||||
return Path(config_home) / "config.yaml"
|
||||
return default_path or _GLOBAL_CONFIG_PATH
|
||||
|
||||
|
||||
def load_global_config(path: Path | None = None) -> dict[str, Any]: # type: ignore[explicit-any]
|
||||
"""Load the user-level config, returning an empty mapping when absent."""
|
||||
resolved_path = path or global_config_path()
|
||||
if not resolved_path.exists():
|
||||
return {}
|
||||
with resolved_path.open() as config_file:
|
||||
raw: dict[str, Any] = yaml.safe_load(config_file) or {} # type: ignore[explicit-any]
|
||||
return raw
|
||||
|
||||
|
||||
def load_local_config(path: Path | None = None) -> dict[str, Any]: # type: ignore[explicit-any]
|
||||
"""Load the project-level config, returning an empty mapping when absent."""
|
||||
resolved_path = path or Path.cwd() / _LOCAL_CONFIG_RELPATH
|
||||
if not resolved_path.exists():
|
||||
return {}
|
||||
with resolved_path.open() as config_file:
|
||||
raw: dict[str, Any] = yaml.safe_load(config_file) or {} # type: ignore[explicit-any]
|
||||
return raw
|
||||
|
||||
|
||||
def load_effective_config() -> dict[str, Any]: # type: ignore[explicit-any]
|
||||
"""Merge user and project config, with project values taking precedence."""
|
||||
return {**load_global_config(), **load_local_config()}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"global_config_path",
|
||||
"load_effective_config",
|
||||
"load_global_config",
|
||||
"load_local_config",
|
||||
]
|
||||
+6
-259
@@ -1,67 +1,20 @@
|
||||
"""Advisor verdict contract for per-turn brain-model selection.
|
||||
"""Cost-control label namespace shared between the server and runner.
|
||||
|
||||
THE interface between the per-turn cost advisor
|
||||
(:mod:`omnigent.runner.cost_advisor`) and the session label that
|
||||
records what it decided: the advisor serializes an
|
||||
:class:`AdvisorVerdict` into ONE conversation label
|
||||
(:data:`COST_CONTROL_PLAN_LABEL`, JSON-encoded) and readers parse it
|
||||
back with :func:`parse_verdict`. Anything that needs to agree on "what
|
||||
did the advisor decide for this turn's brain" goes through this module
|
||||
— never through ad-hoc dicts.
|
||||
|
||||
The advisor v3 contract (this module): a per-user-turn LLM judge picks
|
||||
ONE model for the ORCHESTRATOR'S OWN BRAIN, sized to the turn's
|
||||
difficulty (difficult coding → expensive tier, medium knowledge work →
|
||||
medium, trivial → cheap). The verdict names a single tier + a single
|
||||
concrete model drawn from that tier's configured list. The orchestrator
|
||||
brain still freely decides how many sub-agents to spawn, which workers,
|
||||
and which worker models — that is correctness (the model-family guard),
|
||||
not cost, and the advisor never touches it.
|
||||
|
||||
What retired with v3: the multi-entry tier PARTITION of v2 (a turn now
|
||||
runs on ONE brain model; a mixed-difficulty query takes the MAX tier
|
||||
its parts need), the ``sys_session_send`` dispatch guard
|
||||
(``cost_guard``), and the advise-mode divergence telemetry (nothing to
|
||||
diverge from once the verdict targets the brain, not dispatches).
|
||||
|
||||
This module is pure: no I/O, no ambient clock (callers pass the turn
|
||||
anchor in); its only project import is the shared model-spelling
|
||||
canonicalizer from :mod:`omnigent.model_override`, so tier ranking and
|
||||
the brain-application layer agree on which spellings name the same
|
||||
model.
|
||||
Defines the label-key prefix that the server reserves for policy-owned
|
||||
cost-control metadata, and the helper that identifies which keys in a
|
||||
client-supplied label map fall under that namespace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Label-key prefix of the policy-owned cost-control namespace. Labels
|
||||
# under it are advisor/runner-written telemetry; the server rejects them
|
||||
# in client-supplied label writes (see ``update_session`` /
|
||||
# under it are runner-written telemetry; the server rejects them in
|
||||
# client-supplied label writes (see ``update_session`` /
|
||||
# ``create_session`` in :mod:`omnigent.server.routes.sessions`).
|
||||
COST_CONTROL_LABEL_NAMESPACE = "cost_control."
|
||||
|
||||
# Conversation label carrying the JSON-encoded advisor verdict for the
|
||||
# session's most recent advised turn.
|
||||
COST_CONTROL_PLAN_LABEL = "cost_control.plan"
|
||||
|
||||
# Schema version serialized into the label. v3 is the single-verdict
|
||||
# brain-model shape; v1 (per-worker draft) and v2 (tier partition) never
|
||||
# carry meaning here. parse_verdict version-gates strictly on v3 and
|
||||
# tolerates a legacy v2 label in an old session by returning None rather
|
||||
# than crashing the reader.
|
||||
PLAN_VERSION = 3
|
||||
|
||||
# Tier names in ascending cost order: cheap < medium < expensive.
|
||||
TIER_ORDER: tuple[str, ...] = ("cheap", "medium", "expensive")
|
||||
|
||||
# Advisor enforcement modes: "optimize" applies the verdict to the
|
||||
# brain; "advise" runs the judge in shadow (records the verdict, leaves
|
||||
# the brain model unchanged).
|
||||
ADVISOR_MODES: tuple[str, ...] = ("advise", "optimize")
|
||||
|
||||
|
||||
def reserved_cost_control_keys(labels: Mapping[str, str]) -> tuple[str, ...]:
|
||||
"""
|
||||
@@ -74,209 +27,3 @@ def reserved_cost_control_keys(labels: Mapping[str, str]) -> tuple[str, ...]:
|
||||
mapping touches no reserved keys.
|
||||
"""
|
||||
return tuple(key for key in labels if key.startswith(COST_CONTROL_LABEL_NAMESPACE))
|
||||
|
||||
|
||||
def tier_rank(tier: str) -> int:
|
||||
"""
|
||||
Return the cost rank of a tier name (lower = cheaper).
|
||||
|
||||
:param tier: A tier name from :data:`TIER_ORDER`, e.g. ``"cheap"``.
|
||||
:returns: The tier's index in :data:`TIER_ORDER`, e.g. ``0``.
|
||||
:raises ValueError: When *tier* is not a known tier name — an
|
||||
unknown tier is a configuration error that must fail loud, not
|
||||
silently rank as cheapest or priciest.
|
||||
"""
|
||||
try:
|
||||
return TIER_ORDER.index(tier)
|
||||
except ValueError:
|
||||
raise ValueError(f"unknown tier {tier!r}; expected one of {TIER_ORDER}") from None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AdvisorVerdict:
|
||||
"""
|
||||
A per-turn brain-model selection produced by the cost advisor.
|
||||
|
||||
The advisor picks ONE model (drawn from one tier's configured list)
|
||||
for the orchestrator's OWN brain this turn, sized to the turn's
|
||||
difficulty. ``applied`` records whether the brain actually ran on
|
||||
that model: ``True`` in optimize mode (the override took effect),
|
||||
``False`` in advise mode (shadow telemetry, brain unchanged) or when
|
||||
a user model pin beat the advisor.
|
||||
|
||||
:param version: Serialization schema version, e.g. ``3``
|
||||
(:data:`PLAN_VERSION`).
|
||||
:param tier: The difficulty tier the judge assigned the turn, one of
|
||||
:data:`TIER_ORDER`, e.g. ``"expensive"``.
|
||||
:param model: The concrete brain model the judge chose from
|
||||
``tier``'s configured list, e.g.
|
||||
``"databricks-claude-opus-4-8"``.
|
||||
:param applied: ``True`` when the brain ran on :attr:`model` this
|
||||
turn (optimize mode, no user pin); ``False`` when the verdict was
|
||||
recorded but not applied (advise mode, or a user model pin won).
|
||||
:param rationale: One-sentence judge explanation, surfaced in the
|
||||
UI and (optimize mode) in the in-turn system note. The judge
|
||||
always produces a string (:mod:`omnigent.runner.cost_judge`
|
||||
substitutes a fallback when the model returns none); ``None`` is
|
||||
reserved for the serialize/parse round-trip's degenerate case,
|
||||
where even an empty rationale would not fit the labels column.
|
||||
:param turn_anchor: Caller-supplied anchor tying the verdict to the
|
||||
turn that produced it (an item id or ISO timestamp), e.g.
|
||||
``"2026-06-10T12:00:00+00:00"``. Callers sample the clock; this
|
||||
module never does.
|
||||
"""
|
||||
|
||||
version: int = PLAN_VERSION
|
||||
tier: str
|
||||
model: str
|
||||
applied: bool
|
||||
rationale: str | None
|
||||
turn_anchor: str
|
||||
|
||||
|
||||
# Conversation labels persist into a varchar(256) column; values longer
|
||||
# than this are rejected wholesale by Postgres.
|
||||
_LABEL_VALUE_MAX_LEN = 256
|
||||
|
||||
# Suffix marking a rationale trimmed to fit the labels column.
|
||||
_TRIM_MARKER = "..."
|
||||
|
||||
|
||||
def verdict_to_label_value(verdict: AdvisorVerdict) -> str:
|
||||
"""
|
||||
Serialize a verdict into the :data:`COST_CONTROL_PLAN_LABEL` value.
|
||||
|
||||
Long judge rationales are trimmed so the value fits the labels
|
||||
column (an oversized value fails the whole write, and the verdict
|
||||
then never surfaces). The full rationale still reaches the UI via the
|
||||
``routing_decision`` transcript item.
|
||||
|
||||
Trimming measures SERIALIZED length, not raw character count.
|
||||
:func:`json.dumps` defaults to ``ensure_ascii=True``, so a non-ASCII
|
||||
char escapes to ``\\uXXXX`` (6 chars) and a quote/backslash to 2;
|
||||
counting raw chars dropped a short non-ASCII rationale wholesale (to
|
||||
``null``) even with column budget to spare. The trim keeps the
|
||||
longest rationale prefix that fits, then appends
|
||||
:data:`_TRIM_MARKER`; only the degenerate case (the other fields
|
||||
alone overflow the column) yields a ``null`` rationale.
|
||||
|
||||
:param verdict: The verdict to serialize.
|
||||
:returns: Compact JSON, e.g. ``'{"applied":true,"model":
|
||||
"databricks-claude-opus-4-8","rationale":"...","tier":
|
||||
"expensive","turn_anchor":"...","version":3}'``, at most
|
||||
:data:`_LABEL_VALUE_MAX_LEN` characters.
|
||||
"""
|
||||
payload = {
|
||||
"version": verdict.version,
|
||||
"tier": verdict.tier,
|
||||
"model": verdict.model,
|
||||
"applied": verdict.applied,
|
||||
"rationale": verdict.rationale,
|
||||
"turn_anchor": verdict.turn_anchor,
|
||||
}
|
||||
serialized = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
||||
if len(serialized) <= _LABEL_VALUE_MAX_LEN or not verdict.rationale:
|
||||
return serialized
|
||||
|
||||
# Serialized chars left for the rationale's escaped CONTENT, after the
|
||||
# rest of the object and the trim marker take their share. base_len is
|
||||
# measured with an empty rationale, so it already counts every other
|
||||
# field's escaping plus the rationale value's two surrounding quotes.
|
||||
base_payload = dict(payload)
|
||||
base_payload["rationale"] = ""
|
||||
base_len = len(json.dumps(base_payload, separators=(",", ":"), sort_keys=True))
|
||||
budget = _LABEL_VALUE_MAX_LEN - base_len - len(_TRIM_MARKER)
|
||||
|
||||
kept = ""
|
||||
if budget > 0:
|
||||
# Largest prefix whose escaped content fits the budget. Escaped
|
||||
# length is monotonic in prefix length, so binary-search it.
|
||||
# ``json.dumps(s)`` wraps the value in quotes, hence the ``- 2``.
|
||||
lo, hi = 0, len(verdict.rationale)
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if len(json.dumps(verdict.rationale[:mid])) - 2 <= budget:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid - 1
|
||||
kept = verdict.rationale[:lo]
|
||||
|
||||
payload["rationale"] = (kept + _TRIM_MARKER) if kept else None
|
||||
return json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
|
||||
def parse_verdict(labels: Mapping[str, str]) -> AdvisorVerdict | None:
|
||||
"""
|
||||
Parse an :class:`AdvisorVerdict` out of a conversation-label mapping.
|
||||
|
||||
Version-gates strictly on v3. A legacy v2 label (a tier partition
|
||||
written by an older runner into a session that predates this build)
|
||||
is TOLERATED: it parses to ``None`` instead of raising, so old
|
||||
sessions keep loading — the advisor simply has no v3 verdict to
|
||||
surface for them. Any other malformed v3 label fails loud, since a
|
||||
corrupt current-version label is a real bug, not legacy data.
|
||||
|
||||
:param labels: The conversation's labels, e.g.
|
||||
``{"cost_control.plan": '{"version": 3, ...}'}``.
|
||||
:returns: The parsed v3 verdict; ``None`` when the label is absent
|
||||
(no advised turn yet) or is a tolerated legacy v2 label. A parsed
|
||||
verdict's ``rationale`` is ``None`` when the writer had to drop it
|
||||
to fit the column (see :func:`verdict_to_label_value`).
|
||||
:raises ValueError: When a v3-shaped label is malformed (bad JSON,
|
||||
wrong field types, unknown tier). A ``null`` rationale is NOT
|
||||
malformed: the writer emits it in the degenerate case, so it
|
||||
round-trips rather than raising.
|
||||
"""
|
||||
raw = labels.get(COST_CONTROL_PLAN_LABEL)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"{COST_CONTROL_PLAN_LABEL} label is not valid JSON: {exc}") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(f"{COST_CONTROL_PLAN_LABEL} label must be a JSON object")
|
||||
version = payload.get("version")
|
||||
if version != PLAN_VERSION:
|
||||
# Legacy v2 (tier partition) / v1 in an old session: tolerate by
|
||||
# ignoring rather than crashing the reader. Only the current
|
||||
# schema is parsed; older shapes carry no v3 verdict.
|
||||
return None
|
||||
tier = payload.get("tier")
|
||||
if not isinstance(tier, str) or tier not in TIER_ORDER:
|
||||
raise ValueError(
|
||||
f"{COST_CONTROL_PLAN_LABEL} verdict has tier {tier!r}; expected one of {TIER_ORDER}"
|
||||
)
|
||||
model = payload.get("model")
|
||||
if not isinstance(model, str) or not model:
|
||||
raise ValueError(f"{COST_CONTROL_PLAN_LABEL} verdict needs a non-empty string model")
|
||||
applied = payload.get("applied")
|
||||
if not isinstance(applied, bool):
|
||||
raise ValueError(f"{COST_CONTROL_PLAN_LABEL} verdict needs a boolean applied field")
|
||||
rationale = payload.get("rationale")
|
||||
if rationale is not None and not isinstance(rationale, str):
|
||||
raise ValueError(
|
||||
f"{COST_CONTROL_PLAN_LABEL} verdict needs a string or null rationale field"
|
||||
)
|
||||
turn_anchor = payload.get("turn_anchor")
|
||||
if not isinstance(turn_anchor, str):
|
||||
raise ValueError(f"{COST_CONTROL_PLAN_LABEL} verdict needs a string turn_anchor field")
|
||||
return AdvisorVerdict(
|
||||
version=PLAN_VERSION,
|
||||
tier=tier,
|
||||
model=model,
|
||||
applied=applied,
|
||||
rationale=rationale,
|
||||
turn_anchor=turn_anchor,
|
||||
)
|
||||
|
||||
|
||||
def describe_verdict(verdict: AdvisorVerdict) -> str:
|
||||
"""
|
||||
Render a verdict as the one-line summary used in notes and logs.
|
||||
|
||||
:param verdict: The verdict to describe.
|
||||
:returns: Summary text, e.g.
|
||||
``"databricks-claude-opus-4-8 (expensive)"``.
|
||||
"""
|
||||
return f"{verdict.model} ({verdict.tier})"
|
||||
|
||||
@@ -41,6 +41,7 @@ from omnigent.host.daemon_launch import (
|
||||
wait_for_host_online,
|
||||
wait_for_runner_online,
|
||||
)
|
||||
from omnigent.native_coding_agents import native_shell_terminal_spec
|
||||
from omnigent.native_terminal import (
|
||||
DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S,
|
||||
)
|
||||
@@ -334,17 +335,9 @@ def _materialize_cursor_agent_spec(tmpdir: Path) -> Path:
|
||||
"cwd": ".",
|
||||
"sandbox": {"type": "none"},
|
||||
},
|
||||
"terminals": {
|
||||
"shell": {
|
||||
"command": "bash",
|
||||
"allow_cwd_override": True,
|
||||
"os_env": {
|
||||
"type": "caller_process",
|
||||
"cwd": ".",
|
||||
"sandbox": {"type": "none"},
|
||||
},
|
||||
},
|
||||
},
|
||||
# Default shell terminal for the web-UI "+ New shell" affordance;
|
||||
# its command follows the user's ``$SHELL`` (zsh/fish/bash).
|
||||
"terminals": native_shell_terminal_spec(),
|
||||
}
|
||||
yaml_path.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8")
|
||||
return yaml_path
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Database package — SQLAlchemy models and Alembic migrations."""
|
||||
|
||||
from omnigent.db.db_models import (
|
||||
DEFAULT_WORKSPACE_ID,
|
||||
Base,
|
||||
SqlAgent,
|
||||
SqlConversation,
|
||||
@@ -8,9 +9,12 @@ from omnigent.db.db_models import (
|
||||
SqlFile,
|
||||
SqlSessionPermission,
|
||||
SqlUser,
|
||||
current_workspace_id,
|
||||
workspace_scope,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_WORKSPACE_ID",
|
||||
"Base",
|
||||
"SqlAgent",
|
||||
"SqlConversation",
|
||||
@@ -18,4 +22,6 @@ __all__ = [
|
||||
"SqlFile",
|
||||
"SqlSessionPermission",
|
||||
"SqlUser",
|
||||
"current_workspace_id",
|
||||
"workspace_scope",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Transparent client-side compression for opaque text columns.
|
||||
|
||||
A handful of columns hold machine-generated JSON or free text that is never
|
||||
queried in SQL or read by hand — per-conversation ``session_state`` /
|
||||
``session_usage``, native ``terminal_launch_args``, comment bodies/anchors, and
|
||||
agent descriptions. Compressing them on the client gives a uniform on-disk size
|
||||
across every backend: MySQL's InnoDB does not compress ``TEXT``/``BLOB`` by
|
||||
default and SQLite never does, so relying on per-backend storage compression
|
||||
would leave those two uncompressed while PostgreSQL (TOAST) compresses.
|
||||
|
||||
Stored layout (bytes), chosen so post-migration and legacy rows coexist without
|
||||
a backfill:
|
||||
|
||||
* **New values are framed:** a leading NUL sentinel (``0x00``) followed by a
|
||||
one-byte codec id and the payload. Valid text in these columns can never
|
||||
start with NUL — PostgreSQL forbids NUL in ``text`` outright, and the JSON
|
||||
they hold always leads with ``{``/``[``/``"`` — so the sentinel is an
|
||||
unambiguous "this row is framed" marker.
|
||||
* **Legacy values are unframed UTF-8 text** (written while the column was
|
||||
``TEXT``). They are detected by the absent sentinel — or, under SQLite's
|
||||
dynamic typing, by arriving as ``str`` — and returned unchanged. Each such
|
||||
row re-frames itself the next time it is written.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import zstandard
|
||||
from sqlalchemy import LargeBinary
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
|
||||
# Leading byte marking a framed (post-migration) value. Legacy text never
|
||||
# begins with NUL, so its presence unambiguously distinguishes the two formats.
|
||||
_SENTINEL = 0x00
|
||||
# Codec ids, stored as the byte after the sentinel.
|
||||
_CODEC_RAW = 0x00 # payload stored uncompressed (below the size threshold)
|
||||
_CODEC_ZSTD = 0x01 # payload compressed with zstd
|
||||
|
||||
# Below this many UTF-8 bytes, zstd's frame overhead outweighs the gain, so the
|
||||
# payload is framed but left uncompressed.
|
||||
_MIN_COMPRESS_BYTES = 64
|
||||
# Write-once / read-rarely columns, so favour ratio over speed. The payloads are
|
||||
# small enough that the window size a high level implies never fills.
|
||||
_LEVEL = 19
|
||||
|
||||
|
||||
def encode(text: str | None) -> bytes | None:
|
||||
"""Frame *text* for storage.
|
||||
|
||||
:param text: The plaintext to store, or ``None``.
|
||||
:returns: ``sentinel + codec + payload`` bytes, or ``None`` when *text* is
|
||||
``None``.
|
||||
"""
|
||||
if text is None:
|
||||
return None
|
||||
raw = text.encode("utf-8")
|
||||
if len(raw) < _MIN_COMPRESS_BYTES:
|
||||
return bytes((_SENTINEL, _CODEC_RAW)) + raw
|
||||
packed = zstandard.ZstdCompressor(level=_LEVEL).compress(raw)
|
||||
return bytes((_SENTINEL, _CODEC_ZSTD)) + packed
|
||||
|
||||
|
||||
def decode(value: bytes | str | memoryview | None) -> str | None:
|
||||
"""Inverse of :func:`encode`; also passes through legacy unframed text.
|
||||
|
||||
:param value: The stored column value: framed bytes, legacy UTF-8 bytes,
|
||||
a legacy ``str`` (SQLite dynamic typing), a ``memoryview`` (some
|
||||
drivers), or ``None``.
|
||||
:returns: The decoded plaintext, or ``None`` when *value* is ``None``.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
# SQLite is dynamically typed: a value written before the column became a
|
||||
# BLOB comes back as ``str``. It is legacy plaintext, unchanged.
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, memoryview):
|
||||
value = value.tobytes()
|
||||
if not value or value[0] != _SENTINEL:
|
||||
# Empty, or legacy UTF-8 text (no sentinel — cannot start with NUL).
|
||||
return value.decode("utf-8")
|
||||
codec, payload = value[1], value[2:]
|
||||
if codec == _CODEC_ZSTD:
|
||||
return zstandard.ZstdDecompressor().decompress(payload).decode("utf-8")
|
||||
return payload.decode("utf-8")
|
||||
|
||||
|
||||
class CompressedText(TypeDecorator):
|
||||
"""A ``str`` column stored as a zstd-compressed ``BLOB`` / ``BYTEA``.
|
||||
|
||||
Transparent at the ORM boundary: callers read and write ``str`` exactly as
|
||||
they would with :class:`~sqlalchemy.Text`, and compression happens on the
|
||||
way in and out. Legacy rows written when the column was ``TEXT`` decode
|
||||
unchanged and re-frame on their next write, so no backfill is required.
|
||||
|
||||
Use only for columns that are never filtered, ordered, or pattern-matched
|
||||
in SQL — the stored bytes are opaque to the database.
|
||||
"""
|
||||
|
||||
impl = LargeBinary
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value: str | None, _dialect: object) -> bytes | None:
|
||||
"""Compress on the way into the database."""
|
||||
return encode(value)
|
||||
|
||||
def process_result_value(
|
||||
self, value: bytes | str | memoryview | None, _dialect: object
|
||||
) -> str | None:
|
||||
"""Decompress on the way out of the database."""
|
||||
return decode(value)
|
||||
@@ -3,14 +3,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from omnigent.db.db_models import SqlAgent
|
||||
from omnigent.db.enum_codecs import AGENT_KIND
|
||||
from omnigent.entities import Agent
|
||||
|
||||
|
||||
def sql_agent_to_entity(row: SqlAgent) -> Agent:
|
||||
def sql_agent_to_entity(row: SqlAgent, session_id: str | None = None) -> Agent:
|
||||
"""
|
||||
Convert a :class:`SqlAgent` ORM row to an :class:`Agent` entity.
|
||||
|
||||
:param row: The SQLAlchemy ORM row to convert.
|
||||
:param session_id: Owning conversation id when this agent is
|
||||
session-scoped; ``None`` for template agents. Callers that know
|
||||
the owning conversation id (e.g. the conversation store) pass it
|
||||
directly; the agent store leaves it ``None`` for templates (where
|
||||
``row.kind`` is the "template" code).
|
||||
:returns: An :class:`Agent` dataclass instance.
|
||||
"""
|
||||
return Agent(
|
||||
@@ -21,5 +27,5 @@ def sql_agent_to_entity(row: SqlAgent) -> Agent:
|
||||
version=row.version,
|
||||
description=row.description,
|
||||
updated_at=row.updated_at,
|
||||
session_id=row.session_id,
|
||||
session_id=None if row.kind == AGENT_KIND["template"] else session_id,
|
||||
)
|
||||
|
||||
+364
-89
@@ -2,14 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
from collections.abc import Iterator
|
||||
from contextvars import ContextVar
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
SmallInteger,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
@@ -17,13 +24,72 @@ from sqlalchemy import (
|
||||
text,
|
||||
true,
|
||||
)
|
||||
from sqlalchemy.dialects.mysql import BINARY as MySQLBinary
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
from omnigent.db.compression import CompressedText
|
||||
|
||||
# 32-byte sha256 digest column. LargeBinary → BYTEA (Postgres) / BLOB (SQLite),
|
||||
# but MySQL cannot index a BLOB without a key-prefix length, so use fixed-length
|
||||
# BINARY(32) there — an exact fit for the digest and fully indexable.
|
||||
_CKSUM32 = LargeBinary(32).with_variant(MySQLBinary(32), "mysql")
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Shared declarative base for all omnigent tables."""
|
||||
|
||||
|
||||
# Default workspace id stamped on every row and used as the leading
|
||||
# member of every composite primary key. 0 is the single-workspace /
|
||||
# unassigned sentinel: with no workspace bound to the request, all rows
|
||||
# live in workspace 0.
|
||||
DEFAULT_WORKSPACE_ID = 0
|
||||
|
||||
# Ambient per-request workspace id. Stores are process-wide singletons, so
|
||||
# the active workspace can't ride on the store instance — it lives here.
|
||||
# OSS leaves this at the default (single-workspace 0); a multi-tenant
|
||||
# deployment (e.g. universe) sets it per request from the authenticated
|
||||
# context (via ``workspace_scope`` in middleware). Reads and inserts
|
||||
# resolve it through ``current_workspace_id()`` so the same store code
|
||||
# scopes to the caller's workspace without threading the id through every
|
||||
# signature — keeping this file byte-identical across deployments.
|
||||
_current_workspace_id: ContextVar[int] = ContextVar(
|
||||
"omnigent_workspace_id", default=DEFAULT_WORKSPACE_ID
|
||||
)
|
||||
|
||||
|
||||
def current_workspace_id() -> int:
|
||||
"""Return the workspace id bound to the active request/context.
|
||||
|
||||
Defaults to :data:`DEFAULT_WORKSPACE_ID` (0) — the single-workspace OSS
|
||||
deployment. Multi-tenant deployments set it per request so every
|
||||
primary-key lookup, filter, and insert scopes to that workspace.
|
||||
"""
|
||||
return _current_workspace_id.get()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def workspace_scope(workspace_id: int) -> Iterator[None]:
|
||||
"""Bind *workspace_id* for the duration of the ``with`` block.
|
||||
|
||||
Used by multi-tenant request middleware (and tests) to scope all
|
||||
store access to one workspace; resets to the prior value on exit so
|
||||
nested / concurrent contexts don't leak.
|
||||
"""
|
||||
token = _current_workspace_id.set(workspace_id)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_current_workspace_id.reset(token)
|
||||
|
||||
|
||||
AGENT_KIND_TEMPLATE = "template"
|
||||
AGENT_KIND_SESSION = "session"
|
||||
|
||||
POLICY_SCOPE_DEFAULT = "default"
|
||||
POLICY_SCOPE_SESSION = "session"
|
||||
|
||||
|
||||
class SqlAgent(Base):
|
||||
"""
|
||||
SQLAlchemy model for the ``agents`` table.
|
||||
@@ -40,40 +106,47 @@ class SqlAgent(Base):
|
||||
``"ag_abc123/a1b2c3d4e5f6..."``.
|
||||
:param version: Monotonic version counter. Starts at 1, incremented
|
||||
on each update via ``PUT /api/agents/{id}``.
|
||||
:param kind: ``"template"`` for server-wide registered agents;
|
||||
``"session"`` for per-conversation copies.
|
||||
:param description: Optional free-text description of the agent's
|
||||
purpose. ``None`` when not provided.
|
||||
:param updated_at: Unix epoch seconds of the last update, or
|
||||
``None`` if the agent has never been updated.
|
||||
:param session_id: Owning conversation/session id for a
|
||||
session-scoped agent. ``None`` for template agents uploaded
|
||||
through ``POST /api/agents``.
|
||||
"""
|
||||
|
||||
__tablename__ = "agents"
|
||||
|
||||
# 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(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))
|
||||
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Enum stored as a stable int code (see omnigent.db.enum_codecs
|
||||
# AGENT_KIND: template=1, session=2). The store converts to/from the
|
||||
# string name at the row↔entity boundary.
|
||||
kind: Mapped[int] = mapped_column(SmallInteger)
|
||||
description: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
|
||||
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
session_id: Mapped[str | None] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_agents_created_at", "created_at"),
|
||||
Index(
|
||||
"ix_agents_template_name",
|
||||
"name",
|
||||
unique=True,
|
||||
sqlite_where=text("session_id IS NULL"),
|
||||
postgresql_where=text("session_id IS NULL"),
|
||||
),
|
||||
Index("ix_agents_session_id", "session_id", unique=True),
|
||||
CheckConstraint("kind IN (1, 2)", name="ck_agents_kind"),
|
||||
Index("ix_agents_created_at", "workspace_id", "created_at", "id"),
|
||||
# Template agents have unique names; session-scoped agents (kind=2)
|
||||
# may reuse the same name. That "unique only within the template set"
|
||||
# rule can't be a partial unique index (MySQL has none), so it is
|
||||
# enforced in the store (SqlAlchemyAgentStore.create). This plain index
|
||||
# backs the (workspace_id, name, kind) lookup that check and get_by_name
|
||||
# do — kind is included so the seek skips same-named session copies
|
||||
# straight to the template row.
|
||||
Index("ix_agents_name", "workspace_id", "name", "kind", "id"),
|
||||
)
|
||||
|
||||
|
||||
@@ -95,6 +168,14 @@ class SqlFile(Base):
|
||||
|
||||
__tablename__ = "files"
|
||||
|
||||
# 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(String(64), primary_key=True)
|
||||
created_at: Mapped[int] = mapped_column(Integer)
|
||||
filename: Mapped[str] = mapped_column(String(512))
|
||||
@@ -103,8 +184,14 @@ class SqlFile(Base):
|
||||
session_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_files_created_at", "created_at"),
|
||||
Index("ix_files_session_id_created_at", "session_id", "created_at", "id"),
|
||||
Index("ix_files_created_at", "workspace_id", "created_at", "id"),
|
||||
Index(
|
||||
"ix_files_session_id_created_at",
|
||||
"workspace_id",
|
||||
"session_id",
|
||||
"created_at",
|
||||
"id",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -135,6 +222,14 @@ class SqlUser(Base):
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
# 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(String(128), primary_key=True)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=false())
|
||||
password_hash: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
@@ -177,8 +272,19 @@ class SqlAccountToken(Base):
|
||||
|
||||
__tablename__ = "account_tokens"
|
||||
|
||||
# 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(String(128), primary_key=True)
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
# Enum stored as a stable int code (see omnigent.db.enum_codecs
|
||||
# ACCOUNT_TOKEN_KIND: invite=1, magic=2). The store converts to/from
|
||||
# the string name at the row↔entity boundary.
|
||||
kind: Mapped[int] = mapped_column(SmallInteger, nullable=False)
|
||||
user_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
created_at: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
@@ -187,8 +293,8 @@ class SqlAccountToken(Base):
|
||||
invited_is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=false())
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("kind IN ('invite', 'magic')", name="ck_account_tokens_kind"),
|
||||
Index("ix_account_tokens_expires_at", "expires_at"),
|
||||
CheckConstraint("kind IN (1, 2)", name="ck_account_tokens_kind"),
|
||||
Index("ix_account_tokens_expires_at", "workspace_id", "expires_at", "id"),
|
||||
)
|
||||
|
||||
|
||||
@@ -215,21 +321,34 @@ class SqlSessionPermission(Base):
|
||||
|
||||
__tablename__ = "session_permissions"
|
||||
|
||||
# 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,
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(128),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
conversation_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
level: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("level IN (1, 2, 3, 4)", name="ck_session_permissions_level"),
|
||||
Index("ix_session_permissions_conversation_id", "conversation_id"),
|
||||
# Lookups by conversation (get_session_owner) filter workspace_id +
|
||||
# conversation_id; user_id trails to complete the PK.
|
||||
Index(
|
||||
"ix_session_permissions_conversation_id",
|
||||
"workspace_id",
|
||||
"conversation_id",
|
||||
"user_id",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -246,8 +365,7 @@ class SqlConversation(Base):
|
||||
created.
|
||||
:param updated_at: Unix epoch seconds when the conversation was
|
||||
last updated (item append, title change, etc.).
|
||||
:param title: Optional human-readable title for the conversation.
|
||||
``None`` when not provided.
|
||||
: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,
|
||||
@@ -312,36 +430,41 @@ class SqlConversation(Base):
|
||||
|
||||
__tablename__ = "conversations"
|
||||
|
||||
# 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(String(64), primary_key=True)
|
||||
created_at: Mapped[int] = mapped_column(Integer)
|
||||
updated_at: Mapped[int] = mapped_column(Integer)
|
||||
title: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
kind: Mapped[str] = mapped_column(String(32), default="default")
|
||||
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(
|
||||
String(64),
|
||||
ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
root_conversation_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
agent_id: Mapped[str | None] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("agents.id", ondelete="CASCADE"),
|
||||
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. FK to hosts.host_id (a unique column); ON DELETE
|
||||
# SET NULL so removing a host clears the binding rather than
|
||||
# orphaning it — and host_id -> NULL keeps the
|
||||
# workspace-required CHECK below satisfied.
|
||||
# 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),
|
||||
ForeignKey("hosts.host_id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
# Per-session reasoning-effort hint, e.g. "high". Nullable;
|
||||
@@ -374,13 +497,13 @@ class SqlConversation(Base):
|
||||
# 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(Text, nullable=True)
|
||||
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(Text, nullable=True)
|
||||
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
|
||||
@@ -390,7 +513,7 @@ class SqlConversation(Base):
|
||||
# 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(Text, nullable=True)
|
||||
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.
|
||||
@@ -410,41 +533,49 @@ class SqlConversation(Base):
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("kind IN ('default', 'sub_agent')", name="ck_conversations_kind"),
|
||||
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", "created_at"),
|
||||
Index("ix_conversations_updated_at", "updated_at"),
|
||||
Index("ix_conversations_kind", "kind"),
|
||||
# Reconnect reconciliation queries conversations by host_id on
|
||||
# every host reconnect; index it to avoid a full scan.
|
||||
Index("ix_conversations_host_id", "host_id"),
|
||||
Index("ix_conversations_root_conversation_id", "root_conversation_id"),
|
||||
# Phase 4: partial unique index on (parent_conversation_id,
|
||||
# title) prevents two same-named children under the same
|
||||
# parent (G36 race protection at the DB layer). The
|
||||
# ``sqlite_where`` / ``postgresql_where`` clauses scope the
|
||||
# index so multiple top-level conversations (NULL parent)
|
||||
# remain valid.
|
||||
Index("ix_conversations_created_at", "workspace_id", "created_at", "id"),
|
||||
Index("ix_conversations_updated_at", "workspace_id", "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 (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",
|
||||
"parent_conversation_id",
|
||||
"title",
|
||||
unique=True,
|
||||
sqlite_where=text("parent_conversation_id IS NOT NULL"),
|
||||
postgresql_where=text("parent_conversation_id IS NOT NULL"),
|
||||
mysql_length={"title": 512},
|
||||
),
|
||||
# Partial 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",
|
||||
"parent_conversation_id",
|
||||
text("created_at DESC"),
|
||||
text("id DESC"),
|
||||
sqlite_where=text("kind = 'sub_agent'"),
|
||||
postgresql_where=text("kind = 'sub_agent'"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -480,15 +611,32 @@ class SqlConversationItem(Base):
|
||||
|
||||
__tablename__ = "conversation_items"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
conversation_id: Mapped[str] = mapped_column(
|
||||
String(64), ForeignKey("conversations.id", ondelete="CASCADE")
|
||||
# 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,
|
||||
)
|
||||
# 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(
|
||||
String(64),
|
||||
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)
|
||||
status: Mapped[str] = mapped_column(String(32), default="completed")
|
||||
# Enum stored as a stable int code (see omnigent.db.enum_codecs
|
||||
# ITEM_STATUS: completed=1). Only "completed" is written today, but the
|
||||
# CHECK admits the wider OpenAI-style status vocabulary reserved there.
|
||||
status: Mapped[int] = mapped_column(SmallInteger, default=1)
|
||||
position: Mapped[int] = mapped_column(Integer)
|
||||
type: Mapped[str] = mapped_column(String(32))
|
||||
# Enum stored as a stable int code (see omnigent.db.enum_codecs
|
||||
# ITEM_TYPE). The store converts to/from the string name at the
|
||||
# row↔entity boundary.
|
||||
type: Mapped[int] = mapped_column(SmallInteger)
|
||||
data: Mapped[str] = mapped_column(Text)
|
||||
search_text: Mapped[str] = mapped_column(Text)
|
||||
created_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
@@ -496,11 +644,25 @@ class SqlConversationItem(Base):
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_conversation_items_conversation_id_position",
|
||||
"workspace_id",
|
||||
"conversation_id",
|
||||
"position",
|
||||
unique=True,
|
||||
),
|
||||
Index("ix_conversation_items_response_id", "response_id"),
|
||||
# Fork-truncation looks up by workspace_id + conversation_id +
|
||||
# response_id; id trails to complete the PK.
|
||||
Index(
|
||||
"ix_conversation_items_response_id",
|
||||
"workspace_id",
|
||||
"conversation_id",
|
||||
"response_id",
|
||||
"id",
|
||||
),
|
||||
CheckConstraint(
|
||||
"type IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)",
|
||||
name="ck_conversation_items_type",
|
||||
),
|
||||
CheckConstraint("status IN (1, 2, 3, 4)", name="ck_conversation_items_status"),
|
||||
)
|
||||
|
||||
|
||||
@@ -542,9 +704,16 @@ class SqlConversationLabel(Base):
|
||||
|
||||
__tablename__ = "conversation_labels"
|
||||
|
||||
# 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,
|
||||
)
|
||||
conversation_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
@@ -590,24 +759,64 @@ class SqlComment(Base):
|
||||
|
||||
__tablename__ = "comments"
|
||||
|
||||
# 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(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)
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
body: Mapped[str] = mapped_column(CompressedText)
|
||||
# Enum stored as a stable int code (see omnigent.db.enum_codecs
|
||||
# COMMENT_STATUS: draft=1, addressed=2).
|
||||
status: Mapped[int] = mapped_column(SmallInteger)
|
||||
created_at: Mapped[int] = mapped_column(Integer)
|
||||
updated_at: Mapped[int] = mapped_column(BigInteger)
|
||||
anchor_content: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
anchor_content: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_comments_conversation_id", "conversation_id"),
|
||||
Index("ix_comments_created_at", "created_at"),
|
||||
CheckConstraint("status IN (1, 2)", name="ck_comments_status"),
|
||||
# Serves list_for_conversation: WHERE workspace_id + conversation_id
|
||||
# ORDER BY created_at, id. Folds created_at in (over a bare
|
||||
# conversation_id index) so the sort is index-ordered; trails id to
|
||||
# complete the PK.
|
||||
Index(
|
||||
"ix_comments_conversation_id",
|
||||
"workspace_id",
|
||||
"conversation_id",
|
||||
"created_at",
|
||||
"id",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def policy_name_cksum(name: str) -> bytes:
|
||||
"""Return the sha256 digest of a policy name.
|
||||
|
||||
This 32-byte digest is what the name-uniqueness indexes key on instead
|
||||
of the raw ``VARCHAR(256)`` name — a fixed, compact index entry. Two
|
||||
names collide iff their digests do, so uniqueness is preserved.
|
||||
"""
|
||||
return hashlib.sha256(name.encode("utf-8")).digest()
|
||||
|
||||
|
||||
def _default_policy_name_cksum(context: Any) -> bytes:
|
||||
"""Column default: derive ``name_cksum`` from the bound ``name`` on INSERT.
|
||||
|
||||
Mirrors the ``workspace_id`` default pattern so every ORM insert stamps
|
||||
the checksum without the caller setting it. Column defaults do not fire
|
||||
on UPDATE, so renames recompute it explicitly in the store.
|
||||
"""
|
||||
return policy_name_cksum(context.get_current_parameters()["name"])
|
||||
|
||||
|
||||
class SqlPolicy(Base):
|
||||
"""
|
||||
SQLAlchemy model for the ``policies`` table.
|
||||
@@ -621,9 +830,14 @@ class SqlPolicy(Base):
|
||||
are created via ``POST /v1/policies``.
|
||||
|
||||
:param id: Opaque PK, e.g. ``"pol_a1b2c3..."``.
|
||||
:param name: Human-readable name. UNIQUE per
|
||||
``(session_id, name)`` for session policies; globally
|
||||
unique for default policies (``session_id IS NULL``).
|
||||
:param name: Human-readable name. UNIQUE per session for
|
||||
session policies; globally unique for default policies
|
||||
(``session_id IS NULL``). Uniqueness is enforced on
|
||||
``name_cksum`` rather than this column.
|
||||
:param name_cksum: sha256 digest of ``name`` (32 bytes). The
|
||||
name-uniqueness indexes key on this compact digest instead
|
||||
of the wide ``VARCHAR(256)`` name. Stamped on INSERT by a
|
||||
column default; recomputed by the store on rename.
|
||||
:param session_id: FK to ``conversations.id``. ``None`` for
|
||||
server-wide default policies. ``ON DELETE CASCADE`` so
|
||||
removing a session cleans up its policies.
|
||||
@@ -639,6 +853,10 @@ class SqlPolicy(Base):
|
||||
the handler is a direct callable or for ``type="url"``.
|
||||
:param enabled: Whether the engine consults this row.
|
||||
Defaults to true.
|
||||
:param scope: ``"default"`` for server-wide policies;
|
||||
``"session"`` for session-scoped policies. Explicit
|
||||
discriminator so queries filter by column value instead
|
||||
of checking ``session_id IS NULL``.
|
||||
:param created_by: User ID of the admin who created this
|
||||
policy. ``None`` in single-user mode or for
|
||||
session-scoped policies.
|
||||
@@ -646,17 +864,30 @@ class SqlPolicy(Base):
|
||||
|
||||
__tablename__ = "policies"
|
||||
|
||||
# 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(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
|
||||
# default; the store recomputes it on rename (defaults don't fire on UPDATE).
|
||||
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(
|
||||
String(64),
|
||||
ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[int] = mapped_column(Integer)
|
||||
updated_at: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
type: Mapped[str] = mapped_column(String(16))
|
||||
# Handler discriminator stored as a stable int code (see
|
||||
# omnigent.db.enum_codecs POLICY_TYPE: python=1, url=2).
|
||||
type: Mapped[int] = mapped_column(SmallInteger)
|
||||
# Dotted import path (type="python") or HTTPS URL
|
||||
# (type="url") for the policy handler.
|
||||
handler: Mapped[str] = mapped_column(Text)
|
||||
@@ -666,12 +897,32 @@ class SqlPolicy(Base):
|
||||
# FunctionRef.arguments pattern.
|
||||
factory_params: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, server_default=true())
|
||||
# "default" for server-wide policies; "session" for per-conversation
|
||||
# copies. Mirrors the agents.kind pattern so queries filter by column
|
||||
# value rather than session_id IS NULL. Enum stored as a stable int
|
||||
# code (see omnigent.db.enum_codecs POLICY_SCOPE: default=1, session=2).
|
||||
scope: Mapped[int] = mapped_column(SmallInteger)
|
||||
created_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_policies_created_at", "created_at"),
|
||||
Index("ix_policies_session_id", "session_id"),
|
||||
UniqueConstraint("session_id", "name", name="uq_policies_session_id_name"),
|
||||
CheckConstraint("type IN (1, 2)", name="ck_policies_type"),
|
||||
CheckConstraint("scope IN (1, 2)", name="ck_policies_scope"),
|
||||
Index("ix_policies_created_at", "workspace_id", "created_at", "id"),
|
||||
Index("ix_policies_session_id", "workspace_id", "session_id", "id"),
|
||||
# Name uniqueness keys on name_cksum (sha256 of name) rather than the
|
||||
# wide name column, for a compact 32-byte index entry.
|
||||
UniqueConstraint(
|
||||
"workspace_id",
|
||||
"session_id",
|
||||
"name_cksum",
|
||||
name="uq_policies_session_id_name_cksum",
|
||||
),
|
||||
# Default policies must have unique names; session-scoped policies
|
||||
# may reuse the same name. That "unique only within the default set"
|
||||
# rule can't be a partial unique index (MySQL has none), so it is
|
||||
# enforced in the store (add_default / update_default). This plain
|
||||
# index just backs the name_cksum lookup those checks perform.
|
||||
Index("ix_policies_name_cksum", "workspace_id", "name_cksum", "id"),
|
||||
)
|
||||
|
||||
|
||||
@@ -686,7 +937,8 @@ class SqlHost(Base):
|
||||
:param host_id: Stable host identifier from the host's local
|
||||
``~/.omnigent/config.yaml``, e.g. ``"host_a1b2c3d4e5f6..."``.
|
||||
:param name: Human-readable name from ``config.yaml``, e.g.
|
||||
``"corey-laptop"``. Displayed in the Web UI host picker.
|
||||
``"corey-laptop"``. Displayed in the Web UI host picker. Max 64
|
||||
characters.
|
||||
:param owner: User ID from the Databricks auth Bearer token
|
||||
presented during the host's WebSocket handshake, e.g.
|
||||
``"corey.zumar@databricks.com"``.
|
||||
@@ -727,10 +979,20 @@ class SqlHost(Base):
|
||||
|
||||
__tablename__ = "hosts"
|
||||
|
||||
owner: Mapped[str] = mapped_column(String(256), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(256), primary_key=True)
|
||||
host_id: Mapped[str] = mapped_column(String(64))
|
||||
status: Mapped[str] = mapped_column(String(16))
|
||||
# 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,
|
||||
)
|
||||
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
|
||||
# HOST_STATUS: online=1, offline=2).
|
||||
status: Mapped[int] = mapped_column(SmallInteger)
|
||||
created_at: Mapped[int] = mapped_column(Integer)
|
||||
updated_at: Mapped[int] = mapped_column(Integer)
|
||||
token_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
@@ -741,11 +1003,16 @@ class SqlHost(Base):
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('online', 'offline')",
|
||||
"status IN (1, 2)",
|
||||
name="ck_hosts_status",
|
||||
),
|
||||
UniqueConstraint("host_id", name="uq_hosts_host_id"),
|
||||
UniqueConstraint("token_hash", name="uq_hosts_token_hash"),
|
||||
# (workspace_id, owner, name) was the old PK; keep it unique so the
|
||||
# upsert-on-connect logic (look up by owner+name to detect host_id
|
||||
# rotation) stays consistent.
|
||||
UniqueConstraint("workspace_id", "owner", "name", name="uq_hosts_workspace_owner_name"),
|
||||
# resolve_launch_token filters workspace_id + token_hash, so scoping
|
||||
# the unique to the workspace keeps that lookup index-served.
|
||||
UniqueConstraint("workspace_id", "token_hash", name="uq_hosts_token_hash"),
|
||||
)
|
||||
|
||||
|
||||
@@ -788,6 +1055,14 @@ class SqlUserDailyCost(Base):
|
||||
|
||||
__tablename__ = "user_daily_cost"
|
||||
|
||||
# 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,
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
day_utc: Mapped[str] = mapped_column(String(10), primary_key=True)
|
||||
cost_usd: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Name↔int codecs for enum-like columns stored as ``SMALLINT``.
|
||||
|
||||
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``) 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
|
||||
integer form never leaves the store row↔entity boundary. These codecs are
|
||||
the single place that translates between the two.
|
||||
|
||||
Codes are STABLE and append-only: never renumber or reuse a shipped code,
|
||||
and leave gaps rather than reordering, so old rows keep their meaning.
|
||||
This mirrors :data:`omnigent.server.auth.LEVEL_READ` and friends, the
|
||||
existing int-coded ``session_permissions.level``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from omnigent.entities.conversation import ITEM_TYPE_TO_DATA_CLS
|
||||
|
||||
# ── Code tables (name → stable int code) ───────────────
|
||||
|
||||
CONVERSATION_KIND: dict[str, int] = {
|
||||
"default": 1,
|
||||
"sub_agent": 2,
|
||||
}
|
||||
|
||||
# Item type codes. The key set is kept in lock-step with
|
||||
# ITEM_TYPE_TO_DATA_CLS (the app-layer source of truth) by
|
||||
# _assert_item_type_codes_cover_data_classes below, so a newly added item
|
||||
# type cannot ship without a code. Codes are append-only.
|
||||
ITEM_TYPE: dict[str, int] = {
|
||||
"message": 1,
|
||||
"function_call": 2,
|
||||
"function_call_output": 3,
|
||||
"reasoning": 4,
|
||||
"error": 5,
|
||||
"compaction": 6,
|
||||
"native_tool": 7,
|
||||
"resource_event": 8,
|
||||
"routing_decision": 9,
|
||||
"slash_command": 10,
|
||||
"terminal_command": 11,
|
||||
}
|
||||
|
||||
# Item status codes. Only "completed" is written today (items are final on
|
||||
# append), but the field is semantically an OpenAI-style status that may
|
||||
# widen, so codes for the rest of that vocabulary are reserved up front and
|
||||
# the column CHECK admits all of them.
|
||||
ITEM_STATUS: dict[str, int] = {
|
||||
"completed": 1,
|
||||
"in_progress": 2,
|
||||
"incomplete": 3,
|
||||
"failed": 4,
|
||||
}
|
||||
|
||||
COMMENT_STATUS: dict[str, int] = {
|
||||
"draft": 1,
|
||||
"addressed": 2,
|
||||
}
|
||||
|
||||
ACCOUNT_TOKEN_KIND: dict[str, int] = {
|
||||
"invite": 1,
|
||||
"magic": 2,
|
||||
}
|
||||
|
||||
POLICY_TYPE: dict[str, int] = {
|
||||
"python": 1,
|
||||
"url": 2,
|
||||
}
|
||||
|
||||
HOST_STATUS: dict[str, int] = {
|
||||
"online": 1,
|
||||
"offline": 2,
|
||||
}
|
||||
|
||||
AGENT_KIND: dict[str, int] = {
|
||||
"template": 1,
|
||||
"session": 2,
|
||||
}
|
||||
|
||||
POLICY_SCOPE: dict[str, int] = {
|
||||
"default": 1,
|
||||
"session": 2,
|
||||
}
|
||||
|
||||
|
||||
def _assert_item_type_codes_cover_data_classes() -> None:
|
||||
"""
|
||||
Guard that :data:`ITEM_TYPE` matches the app's item-type registry.
|
||||
|
||||
Raised at import time (and asserted by a unit test) so a new item type
|
||||
added to ``ITEM_TYPE_TO_DATA_CLS`` without a corresponding code fails
|
||||
loudly instead of silently breaking persistence.
|
||||
|
||||
:raises RuntimeError: If the two key sets diverge.
|
||||
"""
|
||||
missing = set(ITEM_TYPE_TO_DATA_CLS) - set(ITEM_TYPE)
|
||||
extra = set(ITEM_TYPE) - set(ITEM_TYPE_TO_DATA_CLS)
|
||||
if missing or extra:
|
||||
raise RuntimeError(
|
||||
"ITEM_TYPE codes are out of sync with ITEM_TYPE_TO_DATA_CLS "
|
||||
f"(missing codes for {sorted(missing)}, "
|
||||
f"unknown types {sorted(extra)})."
|
||||
)
|
||||
|
||||
|
||||
_assert_item_type_codes_cover_data_classes()
|
||||
|
||||
|
||||
# ── Encode / decode ────────────────────────────────────
|
||||
|
||||
|
||||
def _invert(table: dict[str, int]) -> dict[int, str]:
|
||||
"""Return the code→name inverse of a name→code table."""
|
||||
return {code: name for name, code in table.items()}
|
||||
|
||||
|
||||
_CODE_TO_NAME: dict[int, dict[int, str]] = {}
|
||||
|
||||
|
||||
def _encode(table: dict[str, int], name: str, *, field: str) -> int:
|
||||
"""
|
||||
Map an enum *name* to its stable integer code.
|
||||
|
||||
:param table: The name→code table for the field.
|
||||
:param name: The string enum name, e.g. ``"sub_agent"``.
|
||||
:param field: Field label used in the error message, e.g.
|
||||
``"conversations.kind"``.
|
||||
:returns: The integer code.
|
||||
:raises ValueError: If *name* is not a known value for the field.
|
||||
"""
|
||||
try:
|
||||
return table[name]
|
||||
except KeyError:
|
||||
raise ValueError(f"unknown {field} value: {name!r}") from None
|
||||
|
||||
|
||||
def _decode(table: dict[str, int], code: int, *, field: str) -> str:
|
||||
"""
|
||||
Map an integer *code* back to its enum name.
|
||||
|
||||
:param table: The name→code table for the field.
|
||||
:param code: The stored integer code.
|
||||
:param field: Field label used in the error message, e.g.
|
||||
``"conversations.kind"``.
|
||||
:returns: The string enum name.
|
||||
:raises ValueError: If *code* is not a known code for the field.
|
||||
"""
|
||||
inverse = _CODE_TO_NAME.get(id(table))
|
||||
if inverse is None:
|
||||
inverse = _invert(table)
|
||||
_CODE_TO_NAME[id(table)] = inverse
|
||||
try:
|
||||
return inverse[code]
|
||||
except KeyError:
|
||||
raise ValueError(f"unknown {field} code: {code!r}") from None
|
||||
|
||||
|
||||
def encode_conversation_kind(name: str) -> int:
|
||||
"""Encode a ``conversations.kind`` name to its int code."""
|
||||
return _encode(CONVERSATION_KIND, name, field="conversations.kind")
|
||||
|
||||
|
||||
def decode_conversation_kind(code: int) -> str:
|
||||
"""Decode a ``conversations.kind`` int code to its name."""
|
||||
return _decode(CONVERSATION_KIND, code, field="conversations.kind")
|
||||
|
||||
|
||||
def encode_item_type(name: str) -> int:
|
||||
"""Encode a ``conversation_items.type`` name to its int code."""
|
||||
return _encode(ITEM_TYPE, name, field="conversation_items.type")
|
||||
|
||||
|
||||
def decode_item_type(code: int) -> str:
|
||||
"""Decode a ``conversation_items.type`` int code to its name."""
|
||||
return _decode(ITEM_TYPE, code, field="conversation_items.type")
|
||||
|
||||
|
||||
def encode_item_status(name: str) -> int:
|
||||
"""Encode a ``conversation_items.status`` name to its int code."""
|
||||
return _encode(ITEM_STATUS, name, field="conversation_items.status")
|
||||
|
||||
|
||||
def decode_item_status(code: int) -> str:
|
||||
"""Decode a ``conversation_items.status`` int code to its name."""
|
||||
return _decode(ITEM_STATUS, code, field="conversation_items.status")
|
||||
|
||||
|
||||
def encode_comment_status(name: str) -> int:
|
||||
"""Encode a ``comments.status`` name to its int code."""
|
||||
return _encode(COMMENT_STATUS, name, field="comments.status")
|
||||
|
||||
|
||||
def decode_comment_status(code: int) -> str:
|
||||
"""Decode a ``comments.status`` int code to its name."""
|
||||
return _decode(COMMENT_STATUS, code, field="comments.status")
|
||||
|
||||
|
||||
def encode_account_token_kind(name: str) -> int:
|
||||
"""Encode an ``account_tokens.kind`` name to its int code."""
|
||||
return _encode(ACCOUNT_TOKEN_KIND, name, field="account_tokens.kind")
|
||||
|
||||
|
||||
def decode_account_token_kind(code: int) -> str:
|
||||
"""Decode an ``account_tokens.kind`` int code to its name."""
|
||||
return _decode(ACCOUNT_TOKEN_KIND, code, field="account_tokens.kind")
|
||||
|
||||
|
||||
def encode_policy_type(name: str) -> int:
|
||||
"""Encode a ``policies.type`` name to its int code."""
|
||||
return _encode(POLICY_TYPE, name, field="policies.type")
|
||||
|
||||
|
||||
def decode_policy_type(code: int) -> str:
|
||||
"""Decode a ``policies.type`` int code to its name."""
|
||||
return _decode(POLICY_TYPE, code, field="policies.type")
|
||||
|
||||
|
||||
def encode_host_status(name: str) -> int:
|
||||
"""Encode a ``hosts.status`` name to its int code."""
|
||||
return _encode(HOST_STATUS, name, field="hosts.status")
|
||||
|
||||
|
||||
def decode_host_status(code: int) -> str:
|
||||
"""Decode a ``hosts.status`` int code to its name."""
|
||||
return _decode(HOST_STATUS, code, field="hosts.status")
|
||||
|
||||
|
||||
def encode_agent_kind(name: str) -> int:
|
||||
"""Encode an ``agents.kind`` name to its int code."""
|
||||
return _encode(AGENT_KIND, name, field="agents.kind")
|
||||
|
||||
|
||||
def decode_agent_kind(code: int) -> str:
|
||||
"""Decode an ``agents.kind`` int code to its name."""
|
||||
return _decode(AGENT_KIND, code, field="agents.kind")
|
||||
|
||||
|
||||
def encode_policy_scope(name: str) -> int:
|
||||
"""Encode a ``policies.scope`` name to its int code."""
|
||||
return _encode(POLICY_SCOPE, name, field="policies.scope")
|
||||
|
||||
|
||||
def decode_policy_scope(code: int) -> str:
|
||||
"""Decode a ``policies.scope`` int code to its name."""
|
||||
return _decode(POLICY_SCOPE, code, field="policies.scope")
|
||||
@@ -76,6 +76,7 @@ def upgrade() -> None:
|
||||
unique=True,
|
||||
sqlite_where=sa.text("parent_conversation_id IS NOT NULL"),
|
||||
postgresql_where=sa.text("parent_conversation_id IS NOT NULL"),
|
||||
mysql_length={"title": 512},
|
||||
)
|
||||
op.create_table(
|
||||
"files",
|
||||
@@ -231,6 +232,16 @@ def upgrade() -> None:
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("conversation_labels")
|
||||
# MySQL requires FK constraints to be dropped before the indexes that back them.
|
||||
if op.get_bind().dialect.name == "mysql":
|
||||
with op.batch_alter_table("pending_tool_calls") as batch_op:
|
||||
for fk in sa.inspect(op.get_bind()).get_foreign_keys("pending_tool_calls"):
|
||||
if fk["name"]:
|
||||
batch_op.drop_constraint(fk["name"], type_="foreignkey")
|
||||
with op.batch_alter_table("tasks") as batch_op:
|
||||
for fk in sa.inspect(op.get_bind()).get_foreign_keys("tasks"):
|
||||
if fk["name"]:
|
||||
batch_op.drop_constraint(fk["name"], type_="foreignkey")
|
||||
op.drop_index("ix_pending_tool_calls_task_id", table_name="pending_tool_calls")
|
||||
op.drop_index("ix_pending_tool_calls_root_task_id", table_name="pending_tool_calls")
|
||||
op.drop_table("pending_tool_calls")
|
||||
|
||||
@@ -51,5 +51,9 @@ def upgrade() -> None:
|
||||
def downgrade() -> None:
|
||||
# ── conversations ─────────────────────────────────────
|
||||
with op.batch_alter_table("conversations") as batch_op:
|
||||
batch_op.drop_constraint("fk_conversations_agent_id", type_="foreignkey")
|
||||
if any(
|
||||
f["name"] == "fk_conversations_agent_id"
|
||||
for f in sa.inspect(op.get_bind()).get_foreign_keys("conversations")
|
||||
):
|
||||
batch_op.drop_constraint("fk_conversations_agent_id", type_="foreignkey")
|
||||
batch_op.drop_column("agent_id")
|
||||
|
||||
@@ -21,17 +21,26 @@ down_revision: str | None = "b2c3d4e5f6a7"
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Restructure policies table for session-scoped handler policies."""
|
||||
# MySQL requires dropping FK constraints before the indexes/unique constraints
|
||||
# that back them. Drop any FK on agent_id before dropping the unique constraint.
|
||||
if op.get_bind().dialect.name == "mysql":
|
||||
with op.batch_alter_table("policies") as batch_op:
|
||||
for fk in sa.inspect(op.get_bind()).get_foreign_keys("policies"):
|
||||
if fk["name"] and "agent_id" in fk["constrained_columns"]:
|
||||
batch_op.drop_constraint(fk["name"], type_="foreignkey")
|
||||
with op.batch_alter_table("policies") as batch_op:
|
||||
batch_op.add_column(sa.Column("session_id", sa.String(64), nullable=True))
|
||||
batch_op.add_column(sa.Column("handler", sa.Text(), nullable=True))
|
||||
batch_op.add_column(sa.Column("factory_params", sa.Text(), nullable=True))
|
||||
batch_op.create_foreign_key(
|
||||
"fk_policies_session_id",
|
||||
"conversations",
|
||||
["session_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
# MySQL: skip FK creation (FKs removed in p1a2b3c4d5e6 anyway).
|
||||
if op.get_bind().dialect.name != "mysql":
|
||||
batch_op.create_foreign_key(
|
||||
"fk_policies_session_id",
|
||||
"conversations",
|
||||
["session_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
batch_op.create_index("ix_policies_session_id", ["session_id"])
|
||||
batch_op.create_unique_constraint("uq_policies_session_id_name", ["session_id", "name"])
|
||||
batch_op.drop_index("ix_policies_agent_id")
|
||||
@@ -44,16 +53,42 @@ def upgrade() -> None:
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Restore agent-scoped columns and remove session-scoped ones."""
|
||||
# MySQL doesn't allow DEFAULT on TEXT columns; add as nullable then
|
||||
# tighten nullable after — the table is being downgraded so no live rows exist.
|
||||
mysql = op.get_bind().dialect.name == "mysql"
|
||||
with op.batch_alter_table("policies") as batch_op:
|
||||
batch_op.add_column(sa.Column("prompt", sa.Text(), nullable=False, server_default=""))
|
||||
batch_op.add_column(sa.Column("phases", sa.Text(), nullable=False, server_default="[]"))
|
||||
batch_op.add_column(sa.Column("actions", sa.Text(), nullable=False, server_default="[]"))
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"prompt",
|
||||
sa.Text(),
|
||||
nullable=mysql,
|
||||
server_default=None if mysql else "",
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"phases",
|
||||
sa.Text(),
|
||||
nullable=mysql,
|
||||
server_default=None if mysql else "[]",
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"actions",
|
||||
sa.Text(),
|
||||
nullable=mysql,
|
||||
server_default=None if mysql else "[]",
|
||||
)
|
||||
)
|
||||
batch_op.add_column(sa.Column("agent_id", sa.String(64), nullable=True))
|
||||
batch_op.create_unique_constraint("uq_policies_agent_id_name", ["agent_id", "name"])
|
||||
batch_op.create_index("ix_policies_agent_id", ["agent_id"])
|
||||
batch_op.drop_constraint("uq_policies_session_id_name", type_="unique")
|
||||
batch_op.drop_index("ix_policies_session_id")
|
||||
batch_op.drop_constraint("fk_policies_session_id", type_="foreignkey")
|
||||
# MySQL: FK was never added in upgrade (skipped for MySQL compatibility).
|
||||
if not mysql:
|
||||
batch_op.drop_constraint("fk_policies_session_id", type_="foreignkey")
|
||||
batch_op.drop_column("factory_params")
|
||||
batch_op.drop_column("handler")
|
||||
batch_op.drop_column("session_id")
|
||||
|
||||
@@ -55,22 +55,30 @@ def upgrade() -> None:
|
||||
# SET NULL clears the binding when a host is removed, which keeps
|
||||
# the workspace-required check satisfied (host_id -> NULL).
|
||||
batch_op.create_index("ix_conversations_host_id", ["host_id"])
|
||||
batch_op.create_foreign_key(
|
||||
"fk_conversations_host_id_hosts",
|
||||
"hosts",
|
||||
["host_id"],
|
||||
["host_id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
# MySQL 8.0.16+ forbids a column from appearing in both a CHECK
|
||||
# constraint and a FK referential action. Skip FK creation on MySQL
|
||||
# since migration p1a2b3c4d5e6 removes all FKs anyway.
|
||||
if op.get_bind().dialect.name != "mysql":
|
||||
batch_op.create_foreign_key(
|
||||
"fk_conversations_host_id_hosts",
|
||||
"hosts",
|
||||
["host_id"],
|
||||
["host_id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop the host_id FK + index, then the workspace column and check."""
|
||||
mysql = op.get_bind().dialect.name == "mysql"
|
||||
with op.batch_alter_table("conversations") as batch_op:
|
||||
batch_op.drop_constraint(
|
||||
"fk_conversations_host_id_hosts",
|
||||
type_="foreignkey",
|
||||
)
|
||||
# FK was never created on MySQL (skipped in upgrade due to MySQL 8.0.16+
|
||||
# restriction on columns used in both CHECK and FK referential actions).
|
||||
if not mysql:
|
||||
batch_op.drop_constraint(
|
||||
"fk_conversations_host_id_hosts",
|
||||
type_="foreignkey",
|
||||
)
|
||||
batch_op.drop_index("ix_conversations_host_id")
|
||||
batch_op.drop_constraint(
|
||||
"ck_conversations_workspace_required_for_host",
|
||||
|
||||
@@ -32,6 +32,12 @@ depends_on: str | Sequence[str] | None = None
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Drop the tasks table and all of its indexes."""
|
||||
# MySQL requires dropping FK constraints before the indexes that back them.
|
||||
if op.get_bind().dialect.name == "mysql":
|
||||
with op.batch_alter_table("tasks") as batch_op:
|
||||
for fk in sa.inspect(op.get_bind()).get_foreign_keys("tasks"):
|
||||
if fk["name"]:
|
||||
batch_op.drop_constraint(fk["name"], type_="foreignkey")
|
||||
with op.batch_alter_table("tasks") as batch_op:
|
||||
batch_op.drop_index("ix_tasks_conversation_id")
|
||||
batch_op.drop_index("ix_tasks_agent_id")
|
||||
|
||||
@@ -111,7 +111,8 @@ def downgrade() -> None:
|
||||
op.execute(sa.text("DELETE FROM agents WHERE session_id IS NOT NULL"))
|
||||
with op.batch_alter_table("agents") as batch_op:
|
||||
batch_op.drop_index("ix_agents_template_name")
|
||||
batch_op.drop_index("ix_agents_session_id")
|
||||
# MySQL requires the FK to be dropped before the index that backs it.
|
||||
batch_op.drop_constraint("fk_agents_session_id", type_="foreignkey")
|
||||
batch_op.drop_index("ix_agents_session_id")
|
||||
batch_op.drop_column("session_id")
|
||||
batch_op.create_unique_constraint("uq_agents_name", ["name"])
|
||||
|
||||
@@ -63,27 +63,44 @@ def upgrade() -> None:
|
||||
# root_id. Each iteration covers one additional level of the
|
||||
# spawn tree; loops until the UPDATE affects zero rows. Bounded
|
||||
# by the maximum tree depth, which is small in practice.
|
||||
#
|
||||
# MySQL does not allow referencing the same table in a subquery
|
||||
# inside an UPDATE statement (error 1093). Use a JOIN-based UPDATE
|
||||
# for MySQL and the standard subquery form for SQLite/PostgreSQL.
|
||||
bind = op.get_bind()
|
||||
for _ in range(64):
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE conversations
|
||||
SET root_conversation_id = (
|
||||
SELECT parent.root_conversation_id
|
||||
FROM conversations AS parent
|
||||
WHERE parent.id = conversations.parent_conversation_id
|
||||
)
|
||||
WHERE root_conversation_id IS NULL
|
||||
AND parent_conversation_id IS NOT NULL
|
||||
AND (
|
||||
SELECT parent.root_conversation_id
|
||||
FROM conversations AS parent
|
||||
WHERE parent.id = conversations.parent_conversation_id
|
||||
) IS NOT NULL
|
||||
"""
|
||||
)
|
||||
is_mysql = bind.dialect.name == "mysql"
|
||||
if is_mysql:
|
||||
backfill_sql = sa.text(
|
||||
"""
|
||||
UPDATE conversations
|
||||
JOIN conversations AS parent
|
||||
ON parent.id = conversations.parent_conversation_id
|
||||
SET conversations.root_conversation_id = parent.root_conversation_id
|
||||
WHERE conversations.root_conversation_id IS NULL
|
||||
AND conversations.parent_conversation_id IS NOT NULL
|
||||
AND parent.root_conversation_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
else:
|
||||
backfill_sql = sa.text(
|
||||
"""
|
||||
UPDATE conversations
|
||||
SET root_conversation_id = (
|
||||
SELECT parent.root_conversation_id
|
||||
FROM conversations AS parent
|
||||
WHERE parent.id = conversations.parent_conversation_id
|
||||
)
|
||||
WHERE root_conversation_id IS NULL
|
||||
AND parent_conversation_id IS NOT NULL
|
||||
AND (
|
||||
SELECT parent.root_conversation_id
|
||||
FROM conversations AS parent
|
||||
WHERE parent.id = conversations.parent_conversation_id
|
||||
) IS NOT NULL
|
||||
"""
|
||||
)
|
||||
for _ in range(64):
|
||||
result = bind.execute(backfill_sql)
|
||||
if result.rowcount == 0:
|
||||
break
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user