diff --git a/coworker/audit.py b/coworker/audit.py index 349d20b1..d05a6976 100644 --- a/coworker/audit.py +++ b/coworker/audit.py @@ -44,9 +44,27 @@ class AuditStore: args TEXT, result_preview TEXT, reason TEXT, - resource TEXT + resource TEXT, + call_id TEXT, + tokens_in INTEGER DEFAULT 0, + tokens_out INTEGER DEFAULT 0 ) """) + # Existing databases predate the reviewer columns (2026-08-12): call_id joins a + # shadow verdict to the human's decision on the same tool call, tokens_in/out are + # the reviewer metering (§1.7). ALTER is idempotent-by-error: "duplicate column" + # means an already-migrated file. + for column, decl in ( + ("call_id", "TEXT"), + ("tokens_in", "INTEGER DEFAULT 0"), + ("tokens_out", "INTEGER DEFAULT 0"), + ): + try: + self._conn.execute( + f"ALTER TABLE audit_events ADD COLUMN {column} {decl}" + ) + except sqlite3.OperationalError: + pass # column already exists self._conn.commit() def append(self, event: dict[str, Any]) -> None: @@ -60,8 +78,8 @@ class AuditStore: self._conn.execute( """ INSERT INTO audit_events - (session_id, agent, workspace, connector, tool, stage, status, approval, args, result_preview, reason, resource) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (session_id, agent, workspace, connector, tool, stage, status, approval, args, result_preview, reason, resource, call_id, tokens_in, tokens_out) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( event.get("session_id") or "", @@ -76,10 +94,43 @@ class AuditStore: _truncate(str(event.get("result_preview") or "")), _truncate(str(event.get("reason") or "")), _truncate(str(resource or "")), + str(event.get("call_id") or ""), + int(event.get("tokens_in") or 0), + int(event.get("tokens_out") or 0), ), ) self._conn.commit() + def reviewer_stats(self, session_id: str) -> dict[str, Any]: + """Per-session Auto-Approve metering (§1.7), computed from the durable rows so it + survives restarts and engine rebuilds. `live` counts stage=reviewer_verdict (the + mode actually deciding); `shadow` counts stage=reviewer_shadow (recording only).""" + + def _bucket(stage: str) -> dict[str, int]: + with self._lock: + rows = self._conn.execute( + """ + SELECT status, COUNT(*) AS n, + COALESCE(SUM(tokens_in), 0) AS tin, + COALESCE(SUM(tokens_out), 0) AS tout + FROM audit_events + WHERE session_id = ? AND stage = ? + GROUP BY status + """, + (session_id, stage), + ).fetchall() + out = {"checks": 0, "allow": 0, "deny": 0, "unsure": 0, "tokens_in": 0, "tokens_out": 0} + for row in rows: + status = str(row["status"]) + if status in ("allow", "deny", "unsure"): + out[status] += int(row["n"]) + out["checks"] += int(row["n"]) + out["tokens_in"] += int(row["tin"]) + out["tokens_out"] += int(row["tout"]) + return out + + return {"live": _bucket("reviewer_verdict"), "shadow": _bucket("reviewer_shadow")} + def list( self, *, diff --git a/coworker/server/app.py b/coworker/server/app.py index a3ead625..848148ec 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -383,6 +383,12 @@ def create_app(manager: SessionManager) -> FastAPI: def get_unattended(session_id: str) -> dict[str, Any]: return {"unattended": manager.unattended.is_unattended(session_id)} + @app.get("/v1/sessions/{session_id}/reviewer-stats") + def get_reviewer_stats(session_id: str) -> dict[str, Any]: + # Auto-Approve metering (§1.7): checks/verdicts/tokens from the durable audit rows. + # Drives the composer's "Auto-Approve · N checks" badge and the mode-menu summary. + return manager.audit_store.reviewer_stats(session_id) + @app.post("/v1/sessions/{session_id}/unattended") def set_unattended(session_id: str, body: dict) -> dict[str, Any]: # The GUI gates the on-transition behind a one-tap confirm; the manager records the diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index ddd258c1..eb052bb0 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -15,6 +15,7 @@ import { getSettings, getPersonas, getInbox, + getReviewerStats, getUnattended, PERSONAS_CHANGED, resolveInboxItem, @@ -315,6 +316,9 @@ export function App() { // expanded sidebar owns its own instance; this one exists so search never disappears with it. const [searchOpen, setSearchOpen] = useState(false); // A pending composer prefill (text + attachments) pushed from the session start panel. + // Auto-Approve metering (§1.7): live reviewer counts for the composer badge. Polled with + // the session inbox; null until the first fetch (badge hidden). + const [reviewerStats, setReviewerStats] = useState(null); const [composerPrefill, setComposerPrefill] = useState<{ text: string; attachments?: Attachment[]; nonce: number }>(); // Persona metadata drives workspace behavior by FAMILY, not by hardcoded id (so a DevOps/SecOps @@ -885,6 +889,7 @@ export function App() { const load = () => { getInbox(sessionId, "pending").then(setSessionInbox).catch(() => setSessionInbox([])); getUnattended(sessionId).then(markUnattended).catch(() => markUnattended(false)); + getReviewerStats(sessionId).then(setReviewerStats).catch(() => setReviewerStats(null)); }; load(); const t = setInterval(load, 4000); @@ -1652,6 +1657,7 @@ export function App() { sessionId={sessionId} workspace={needsWorkspace(agent) ? workspace || "" : undefined} unattended={unattended} + reviewerStats={reviewerStats?.live ?? null} onUnattendedChange={agent !== "chat" ? toggleUnattended : undefined} prefill={composerPrefill} resetKey={sessionId} diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 266f6c30..c290f533 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -1420,6 +1420,25 @@ export async function setUnattended( return res.json(); } +// Auto-Approve metering (§1.7): per-session reviewer counts from the durable audit rows. +export interface ReviewerBucket { + checks: number; + allow: number; + deny: number; + unsure: number; + tokens_in: number; + tokens_out: number; +} +export interface ReviewerStats { + live: ReviewerBucket; // the mode actually deciding (Mode.AUTO_APPROVE) + shadow: ReviewerBucket; // shadow evaluation: recorded next to the human's own decisions +} + +export async function getReviewerStats(sessionId: string): Promise { + const res = await fetch(`${httpBase()}/v1/sessions/${sessionId}/reviewer-stats`); + return res.json(); +} + export async function getSettings(): Promise { const res = await fetch(`${httpBase()}/v1/settings`); return res.json(); diff --git a/surfaces/gui/src/components/Composer.tsx b/surfaces/gui/src/components/Composer.tsx index e233690c..568bc393 100644 --- a/surfaces/gui/src/components/Composer.tsx +++ b/surfaces/gui/src/components/Composer.tsx @@ -97,6 +97,9 @@ interface Props { // when" is one mental model. Absent handler = no toggle (e.g. Chat). unattended?: boolean; onUnattendedChange?: (on: boolean) => void; + // Auto-Approve metering (§1.7): the "Auto-Approve · N checks" badge + mode-menu summary. + // Absent/zero hides both; only the LIVE bucket surfaces here (shadow is a Settings concern). + reviewerStats?: { checks: number; allow: number; deny: number; unsure: number; tokens_in: number; tokens_out: number } | null; approvalSlot?: ReactNode; // Push text + attachments into the composer (e.g. a start-panel task card). The `nonce` makes // repeated identical prefills re-apply; the user can still edit before sending. @@ -599,6 +602,7 @@ export function Composer(props: Props) { ) : props.workspace !== undefined ? ( void; unattended?: boolean; onUnattendedChange?: (on: boolean) => void; + reviewerStats?: { checks: number; allow: number; deny: number; unsure: number; tokens_in: number; tokens_out: number } | null; }) { const [open, setOpen] = useState(false); // The Auto-Approve entry is gated on the server flag. Fetch once on first open; a session @@ -889,6 +895,11 @@ function ModeMenu({ } > {current?.label || mode} + {mode === "auto-approve" && !!reviewerStats?.checks && ( + + · {reviewerStats.checks} {reviewerStats.checks === 1 ? "check" : "checks"} + + )} {open && ( @@ -923,6 +934,18 @@ function ModeMenu({ {o.description} ))} + {mode === "auto-approve" && !!reviewerStats?.checks && ( + <> +
+
+ This session: {reviewerStats.checks} {reviewerStats.checks === 1 ? "check" : "checks"} ·{" "} + {reviewerStats.allow} cleared · {reviewerStats.deny} blocked · {reviewerStats.unsure} asked you + {reviewerStats.tokens_in + reviewerStats.tokens_out > 0 && ( + <> · ~{Math.round((reviewerStats.tokens_in + reviewerStats.tokens_out) / 100) / 10}k tokens + )} +
+ + )} {onUnattendedChange && ( <>
diff --git a/tests/test_auto_approve_settings.py b/tests/test_auto_approve_settings.py index 2de804ed..f5dcd4cd 100644 --- a/tests/test_auto_approve_settings.py +++ b/tests/test_auto_approve_settings.py @@ -78,3 +78,71 @@ def test_build_engine_override_beats_config(tmp_path, monkeypatch): assert engine.reviewer is not None engine2 = build_engine(agent=chat_agent(), auto_approve=False, auto_approve_shadow=False) assert engine2.reviewer is None + + +# -- metering (§1.7): durable reviewer stats from the audit store ------------------ + + +def test_reviewer_stats_aggregates_by_stage(tmp_path): + from coworker.audit import AuditStore + + store = AuditStore(tmp_path / "audit.db") + sid = "s1" + rows = [ + {"session_id": sid, "tool": "run_shell", "stage": "reviewer_verdict", "status": "allow", "tokens_in": 100, "tokens_out": 20}, + {"session_id": sid, "tool": "run_shell", "stage": "reviewer_verdict", "status": "allow", "tokens_in": 110, "tokens_out": 25}, + {"session_id": sid, "tool": "web_fetch", "stage": "reviewer_verdict", "status": "deny", "tokens_in": 90, "tokens_out": 30}, + {"session_id": sid, "tool": "write_file", "stage": "reviewer_verdict", "status": "unsure", "tokens_in": 80, "tokens_out": 15}, + {"session_id": sid, "tool": "write_file", "stage": "reviewer_shadow", "status": "allow", "tokens_in": 70, "tokens_out": 10}, + # Other sessions and stages must not leak in. + {"session_id": "other", "tool": "run_shell", "stage": "reviewer_verdict", "status": "allow", "tokens_in": 999, "tokens_out": 999}, + {"session_id": sid, "tool": "run_shell", "stage": "finished", "status": "ok"}, + ] + for r in rows: + store.append(r) + + stats = store.reviewer_stats(sid) + assert stats["live"] == { + "checks": 4, "allow": 2, "deny": 1, "unsure": 1, + "tokens_in": 380, "tokens_out": 90, + } + assert stats["shadow"]["checks"] == 1 and stats["shadow"]["allow"] == 1 + store.close() + + +def test_audit_migration_adds_columns_to_a_legacy_db(tmp_path): + import sqlite3 + + from coworker.audit import AuditStore + + # A pre-2026-08-12 database without the reviewer columns. + db = tmp_path / "legacy.db" + conn = sqlite3.connect(db) + conn.execute( + """CREATE TABLE audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT DEFAULT CURRENT_TIMESTAMP, + session_id TEXT, agent TEXT, workspace TEXT, connector TEXT, tool TEXT, + stage TEXT, status TEXT, approval TEXT, args TEXT, result_preview TEXT, + reason TEXT, resource TEXT)""" + ) + conn.execute( + "INSERT INTO audit_events (session_id, tool, stage, status) VALUES ('s1','t','finished','ok')" + ) + conn.commit() + conn.close() + + store = AuditStore(db) # opening migrates + store.append( + {"session_id": "s1", "tool": "run_shell", "stage": "reviewer_verdict", + "status": "allow", "tokens_in": 50, "tokens_out": 9, "call_id": "c1"} + ) + stats = store.reviewer_stats("s1") + assert stats["live"]["checks"] == 1 and stats["live"]["tokens_in"] == 50 + row = [r for r in store.list(session_id="s1") if r["stage"] == "reviewer_verdict"][0] + assert row["call_id"] == "c1" + store.close() + + +def test_reviewer_stats_endpoint(client): + empty = client.get("/v1/sessions/nope/reviewer-stats").json() + assert empty["live"]["checks"] == 0 and empty["shadow"]["checks"] == 0