diff --git a/docs/ultracode-agent-viz-plan.md b/docs/ultracode-agent-viz-plan.md new file mode 100644 index 00000000..8038ece9 --- /dev/null +++ b/docs/ultracode-agent-viz-plan.md @@ -0,0 +1,271 @@ +# Ultracode / Workflow Agent Visualization — Design & Implementation Plan + +> **Status: IMPLEMENTED (2026-06-15, rev. 3) — Phases 1–3 shipped & verified; Phase 4 (live-transcript link) deferred.** A dedicated, opt-in **master-detail tab** (`showUltracodeAgents`, default OFF) shows ultracode/Workflow runs as Claude Code's "working agents" TUI: LEFT = runs + phases (selectable tasks), RIGHT = each run's agents with model, live state, **tokens burned**, and **tool calls**. +> +> ### What rev. 3 changed vs. rev. 2 (decided during implementation against on-disk truth) +> 1. **UI is a master-detail TAB, not grouped floating subagent windows.** The user asked for the CC "working agents" view (left task picker, right agent stats). Built as a new docked panel `#ultracodeAgentsPanel` (clones `.subagents-panel` master-detail CSS) + `src/web/public/ultracode-panel.js` — NOT via `openSubagentWindow`/grouped windows. +> 2. **STANDALONE — zero edits to `subagent-watcher.ts`.** w16-claudeman's commit `f6a30d7` already discovers the per-agent workflow *transcripts* (`watchWorkflowDirs`). The data the view needs (run/phase/per-agent tokens+toolCalls) lives in the *run-state* JSON, read by a brand-new `src/workflow-run-watcher.ts` (globs the disjoint `…/workflows/wf_*.json` tree). No shared files with w16. +> 3. **No per-agent transcript streaming needed for v1.** The run-state JSON already carries `tokens`/`toolCalls`/`state`/`label`/`phase` per agent, so the whole view reads from `wf_.json` alone. (Phase 4 will optionally link a card to its already-tracked transcript via `agentId` — no watcher edits.) +> 4. **Agent states are `start | progress | done`** (verified on disk) — NOT running/queued. `start`=queued (no agentId/tokens/toolCalls yet), `done` has `durationMs`/`resultPreview`. +> 5. **The run JSON's `script` (15–660KB embedded JS), `scriptPath`, `result`, `logs` are STRIPPED in the watcher** before caching/broadcast (a 28-agent run drops 174KB → ~25KB; `promptPreview`/`resultPreview` truncated). +> 6. **SSE/snapshot ship lightweight run SUMMARIES (no `agents[]`); the RIGHT pane fetches the full run** via `GET /api/workflows/:runId` on selection. (A 25-run snapshot is ~20KB vs ~900KB if it carried every agent.) The LEFT list shows ALL cached runs (LRU-bounded), not a recency window — a run browser must show past runs. +> +> _Original rev. 2 proposal (grouped floating windows, extending subagent-watcher) preserved below for context; superseded by the above._ + +### What changed in rev. 2 (vs. the first draft) + +1. **No backend cross-watcher coupling.** The per-agent label/phase/agentType/state **join moves to the frontend at render time** — the run object already carries every agent's entry keyed by `agentId`. This deletes `subagent-watcher`'s backward dependency on `workflow-run-watcher` (`getAgentLabel()` + its TTL cache), removes the registration-vs-run-state **race** (labels always track the latest `workflow:run_updated`), and drops the per-agent `meta.json` read from the hot path. +2. **`SubagentInfo` grows by 2 fields, not 4** (`isWorkflowAgent`, `workflowRunId`) — both derivable from the file path alone at registration, zero extra I/O. `agentType`/`label`/`phase`/`state` come from the run object on the frontend. +3. **The `isInternalAgent` bypass covers BOTH drop sites** — `registerAgentFile` *and* the late re-resolution in `processEntry`. The first draft named only one. +4. **De-duplicated.** Each trap (`journal.jsonl`, the `projects/*/*/workflows` depth, the gate-mismatch lesson, reuse-not-rebuild) is stated once in its owning section. + +### Code-reuse verified against the tree (2026-06-14) + +Confirmed present and shaped as assumed: `subagent-watcher.ts` — `watchSubagentDir`/`registerAgentFile`/`tailFile`/`processEntry`, `getRecentSubagents`, `isInternalAgent` (drops on `MIN_DESCRIPTION_LENGTH=5`), `STARTUP_MAX_FILE_AGE_MS=4h`, `MAX_TRACKED_AGENTS`, `knownSubagentDirs`/`dirWatchers`. `team-watcher.ts` — `configMtimes` mtime-skip + chokidar + `setInterval` poll. `server.ts` — `setupSubagentWatcherListeners`, `getLightState()` (`subagents: getRecentSubagents(15)`, `LIGHT_STATE_CACHE_TTL_MS=1000`), `isSubagentTrackingEnabled()` (`settings.subagentTrackingEnabled ?? true`). Frontend — `_SSE_HANDLER_MAP`, `this.subagents` Map, `handleInit`/`cleanupAllFloatingWindows`, `renderSubagentPanel`/`_renderSubagentPanelImmediate`, `getTeammateBadgeHtml`, `openSubagentWindow` + `.subagent-window-parent` sub-header. + +## 1. The enabling fact: on-disk artifacts + +The Workflow tool (what `ultracode` drives) persists each workflow agent as a transcript under the **same `subagents/` directory Codeman already watches**, one level deeper. Empirically verified against a real run (`wf_a8e09f2c-550`); **re-confirm the shape against a fresh run at implementation time** (§8 mandates a live e2e pass anyway): + +``` +~/.claude/projects/// + ├─ subagents/ + │ ├─ agent-XX.jsonl ← regular Task subagent (tracked today) + │ └─ workflows/wf_/ + │ ├─ agent-YY.jsonl ← WORKFLOW agent — IDENTICAL line format + │ ├─ agent-YY.meta.json ← {"agentType":"workflow-subagent"} (optional enrichment) + │ └─ journal.jsonl ← run journal {type:"started",...} — MUST be skipped + └─ workflows/wf_.json ← run state: runId, workflowName, summary, status, + phases[], workflowProgress[], totals (DIFFERENT tree) +``` + +The per-agent `.jsonl` line shape is identical to a regular subagent transcript: + +```jsonc +{ "parentUuid": null, "isSidechain": true, "agentId": "ac6a1d27012a64e38", + "type": "user" | "assistant", "message": { "role": "...", "content": "..." }, ... } +``` + +Because the line shape is identical, the entire existing parse→event→render pipeline works unchanged once discovery reaches those files. The only new data is the **run-level metadata** in `workflows/wf_.json` (name, summary, phases, and `workflowProgress[]` — the per-agent labels/state/tools), which supplies the group header and per-agent labels. + +**Can show:** per-agent live transcript (tool calls, messages, results); per-agent status (active/idle/completed via the existing mtime/PID/pgrep liveness); per-agent model + running token totals (from each agent's JSONL `message.usage`, exactly as today); the run's `workflowName`/`summary`/`phases[]`; per-agent `label`/`phaseTitle`/`state`/`lastToolName` (from `workflowProgress[]`); grouping under `wf_`. + +**Cannot show:** anything absent from the artifacts — a live phase cursor beyond `workflowProgress[].state`; an authoritative **budget/cost ceiling** (only consumed totals exist — `usage` + run-state `totalTokens`, no remaining-budget field); runs older than `STARTUP_MAX_FILE_AGE_MS` (4h) after a server restart (live monitoring only). + +## 2. Architecture + +**Decision: EXTEND `subagent-watcher.ts` for per-agent discovery/streaming; ADD a thin `workflow-run-watcher.ts` (modeled on `team-watcher.ts`) for the group-header metadata ONLY. The agent→run-metadata join happens on the FRONTEND, so the two watchers stay decoupled.** + +- The per-agent JSONL is identical in shape, so re-running it through `registerAgentFile()` → `tailFile()` → `processEntry()` and the existing `subagent:*` events is free and reconnect-safe (those agents land in `agentInfo`, replayed by `getRecentSubagents(15)`). A parallel per-agent watcher would duplicate the liveness/token/tool-call/SSE machinery for zero benefit. +- Run metadata lives in a *different* file under a *different* tree (`workflows/wf_.json`, sibling to `subagents/`). A small `WorkflowRunWatcher` watching `projects/*/*/workflows/wf_*.json` (mtime-skip, like `team-watcher`'s `configMtimes`) is the clean home; folding it into `subagent-watcher` would entangle two unrelated watch roots and put a JSON re-read in the hot per-line path. +- **The two watchers never call each other.** The frontend receives both streams and joins agent→label by `agentId` at render time (the run object carries every agent's entry). This removes the timing coupling entirely. + +``` +~/.claude/projects/// + ├─ subagents/ + │ ├─ agent-XX.jsonl ──────────────► SubagentWatcher (EXTENDED: also descends + │ └─ workflows/wf_/ workflows/wf_/, tags isWorkflowAgent+runId) + │ ├─ agent-YY.jsonl ─┐ reuse registerAgentFile/tailFile/processEntry + │ └─ journal.jsonl (SKIP) emits subagent:* (now w/ 2 workflow fields) + └─ workflows/wf_.json ──────► WorkflowRunWatcher (NEW, team-watcher-shaped) + {workflowName,phases,workflowProgress[]} emits workflow:run_discovered|updated|removed + + server.ts + setupSubagentWatcherListeners() ──► broadcast(subagent:*) ─┐ + setupWorkflowRunWatcherListeners() ──► broadcast(workflow:run_*) │ SSE + getLightState(): subagents + workflowRuns ───────────────────────┘ + │ + ▼ app.js dispatch table + panels-ui: partition this.subagents by workflowRunId; header + per-agent + labels JOINED from this.workflowRuns.get(runId).agents (by agentId) +``` + +## 3. Backend changes (ordered, file-by-file) + +### 3a. `src/subagent-watcher.ts` — nested discovery + 2 tag fields + +**(1) Extend `SubagentInfo` with exactly two optional fields** (optional → regular subagents and the wire shape are unaffected): + +```ts +isWorkflowAgent?: boolean; // true when discovered under subagents/workflows// +workflowRunId?: string; // e.g. "wf_23dbeab2-152" (parent dir name) +``` + +Both are derived from the **file path alone** at registration — no extra reads. They ride existing `subagent:discovered|updated|completed` payloads (no new per-agent event). Do **not** add `agentType`/`label`/`phase`/`workflowName` here — those come from the run object on the frontend (§4c). + +**(2) Constant.** `const WORKFLOWS_SUBDIR = 'workflows';` near the existing dir constants. + +**(3) `watchSubagentDir()` — descend into `workflows//`.** After the existing direct-child registration loop: + +```ts +// Workflow agents live one level deeper: subagents/workflows//agent-*.jsonl +const wfRoot = join(dir, WORKFLOWS_SUBDIR); +try { + for (const runId of await readdir(wfRoot)) { + if (!runId.startsWith('wf_')) continue; + await this.watchWorkflowRunDir(join(wfRoot, runId), projectHash, sessionId, runId); + } +} catch { /* no workflows subdir — normal for most sessions */ } +``` + +The existing `fs.watch(dir, …)` on `subagents/` is **non-recursive on Linux** and won't fire for writes inside `workflows//`, so each run dir needs its own watcher. + +**(4) New private `watchWorkflowRunDir(runDir, projectHash, sessionId, runId)`** — clone `watchSubagentDir`'s structure, but: +- Register only files matching `^agent-.*\.jsonl$`, **explicitly skipping `journal.jsonl`** (it ends in `.jsonl` but is `{type:'started',…}`, not a transcript — registering it would create a phantom agent). +- Call `registerAgentFile(filePath, projectHash, sessionId, isInitialScan, runId)` so the agent is tagged. +- Install one `watch(runDir, …)` per run dir; on `error` and `stop()`, reuse the existing teardown (close + delete from `dirWatchers`/`knownSubagentDirs`/`dirWatcherErrorHandlers`). +- Guard re-registration **per run dir** in `knownSubagentDirs`, **not** `wfRoot` — the 5s full scan must still re-`readdir(wfRoot)` to pick up *new* `wf_` dirs created mid-session. + +**(5) `registerAgentFile()` — accept + apply `runId`.** Add a trailing optional `runId?: string`. When set, the whole change is: + +```ts +if (runId) { info.isWorkflowAgent = true; info.workflowRunId = runId; } +``` + +No `meta.json` read, no run-state lookup, no description override. `agentId`s are globally unique `a<16hex>` (verified: 0 collisions across a 370-agent corpus), so keep the flat `agentInfo` map keyed by `agentId` — do **not** switch to a composite key. Add a one-line dev-assert log if `agentInfo.has(agentId)` with a *different* `workflowRunId`, so a future collision is observable. + +**(6) `isInternalAgent` bypass — BOTH drop sites.** Workflow agents have no Task-tool spawn record, so `_resolveDescription` yields only the first-user-message fallback (often a long phase prompt) or empty → `isInternalAgent` (`length < MIN_DESCRIPTION_LENGTH`) would wrongly drop them. They are real by construction (the `subagents/workflows/wf_*/` path is the discriminator). Gate the drop on `!info.isWorkflowAgent` at **both** places: +- `registerAgentFile` initial check (`isInternalAgent(description)`), +- `processEntry`'s late re-resolution (the second `isInternalAgent` call). + +**(7) `stop()` teardown.** Per-run watchers live in `dirWatchers`, so the existing close-all loop covers them — verify no separate map was introduced (24h runs spawn many `wf_` dirs → FSWatcher leak risk). + +### 3b. NEW `src/workflow-run-watcher.ts` (singleton, EventEmitter — model on `team-watcher.ts`) + +- **Watch root:** `~/.claude/projects///workflows/wf_*.json` — **two** levels under `projects` (verified: `projects/*/workflows` is empty; must be `projects/*/*/workflows/`). chokidar `depth:3` + a poll fallback, mirroring `team-watcher`'s dual discovery + interval. +- **mtime-skip:** `runMtimes: Map` (mirror `team-watcher.configMtimes`). +- **Parse:** read `wf_.json`, take the **top-level structured keys** (`runId`, `workflowName`, `summary`, `status`, `phases:[{title,detail}]`, `agentCount`, `defaultModel`, `durationMs`, `totalTokens`, `totalToolCalls`, `workflowProgress[]`). **Do NOT parse the embedded `script` string** — name/phases/summary are already top-level; the script's `export const meta` is redundant and costly. Derive `sessionUuid` from the dir name, `projectHash` from the dir above; expose `getProjectHash(workingDir)` for Codeman-session correlation. +- **`workflowProgress[] → agents[]`:** filter `type === 'workflow_agent'`, map each to a `WorkflowAgentEntry` (§3c) keyed by `agentId`. **This array is the join source the frontend uses** — no backend `getAgentLabel()` API, no TTL cache, no import from `subagent-watcher`. +- **Emit** `workflow:run_discovered|updated|removed` carrying `WorkflowRunInfo`; removal by set-diff (mirror `team-watcher`). +- **Lifecycle:** `start()`/`stop()` with `CleanupManager` teardown of chokidar + interval + caches; `LRUMap`-bounded run cache (24h memory rule). + +### 3c. `src/types/` — workflow run types + +```ts +export interface WorkflowAgentEntry { // one workflowProgress[type==='workflow_agent'] + agentId: string; label: string; phaseIndex?: number; phaseTitle?: string; + agentType?: string; model?: string; state?: string; // 'done'|'running'|'queued'|... + lastToolName?: string; lastToolSummary?: string; tokens?: number; toolCalls?: number; +} +export interface WorkflowRunInfo { + runId: string; sessionUuid: string; projectHash: string; + workflowName?: string; summary?: string; status?: string; // 'running'|'completed'|... + phases: Array<{ title: string; detail?: string }>; + agentCount?: number; defaultModel?: string; + agents: WorkflowAgentEntry[]; // workflowProgress filtered to workflow_agent, keyed by agentId + startedAt?: number; durationMs?: number; totalTokens?: number; totalToolCalls?: number; +} +``` + +The two `SubagentInfo` workflow fields stay inline in `subagent-watcher.ts` (matching the existing convention). + +### 3d. `src/web/sse-events.ts` — register run events + +Add `workflow:run_discovered`, `workflow:run_updated`, `workflow:run_removed` after the `subagent:*` block and to the `SseEvent` union. **No new per-agent event** — workflow agents reuse `subagent:*`. + +### 3e. `src/web/server.ts` — bridge, snapshot, gating + +- **`setupWorkflowRunWatcherListeners()`** (beside `setupSubagentWatcherListeners`): map the three run events → `this.broadcast(...)`. Add `cleanupWorkflowRunWatcherListeners()` (store handler refs). +- **Start/stop:** call `workflowRunWatcher.start()`/`.stop()` beside `subagentWatcher`, **gated on the same enable condition** (§3f). +- **`getLightState()`:** add `workflowRuns: workflowRunWatcher.getRecentRuns(15)` beside `subagents: subagentWatcher.getRecentSubagents(15)` so headers replay on reconnect (agents already replay via `subagents`). Keep the `LIGHT_STATE_CACHE_TTL_MS` memoization. +- **Gating read:** add `isWorkflowAgentTrackingEnabled()` mirroring `isSubagentTrackingEnabled()` (boot-time `dataPath('settings.json')` read). Gate `workflowRunWatcher.start()` **and** the subagent-watcher `workflows/` descent (§3a-3) on `showUltracodeAgents` so non-opted-in users never register historical workflow agents. + +### 3f. `src/web/schemas.ts` — settings key + +Add `showUltracodeAgents: z.boolean().optional()` to the `.strict()` settings update schema near `showPlanUsageLimits` (required — `.strict()` 400s the whole PUT on an unknown key). + +### 3g. `src/web/routes/system-routes.ts` — poll API + +- `GET /api/subagents` and `GET /api/sessions/:id/subagents` include workflow agents once registered — **no change** (they carry `isWorkflowAgent`/`workflowRunId`; a consumer joins to `/api/workflows/:runId` for labels). +- Add `GET /api/workflows` → `workflowRunWatcher.getRecentRuns()` and `GET /api/workflows/:runId` (uniform `ApiResponse` contract; headers are also in `getLightState`). +- `GET /api/subagents/:agentId/transcript` works for workflow agents (they're in `agentInfo`) — no new route. + +## 4. Frontend changes (file-by-file) + +### 4a. `src/web/public/constants.js` +- Add the three SSE strings to `SSE_EVENTS`, matching §3d exactly (`WORKFLOW_RUN_DISCOVERED: 'workflow:run_discovered'`, etc.). +- Reuse `ZINDEX_SUBAGENT_BASE=1000` for the agent windows (they ARE subagent windows). The group **header/cluster** is in-flow panel DOM, not a floating window — no new z-index (1100 is plan-subagent). + +### 4b. `src/web/public/app.js` +- Constructor: `this.workflowRuns = new Map(); // runId -> WorkflowRunInfo` beside `this.subagents`. +- `_SSE_HANDLER_MAP`: add three rows → `_onWorkflowRunDiscovered/Updated/Removed` (must exist before `connectSSE` builds the wrappers). +- `handleInit`: after seeding `data.subagents`, seed `this.workflowRuns` from `data.workflowRuns` (clear-then-set). **Clear `this.workflowRuns` everywhere the subagent Maps are cleared** (incl. `cleanupAllFloatingWindows`) — 24h leak guard. + +### 4c. `src/web/public/panels-ui.js` — the join lives here +- `_onWorkflowRunDiscovered/Updated(data)` → `this.workflowRuns.set(data.runId, data)` + debounced re-render; `_onWorkflowRunRemoved` → delete + re-render. +- **No change to `_onSubagentDiscovered/Updated`** — they already store the whole payload, so the 2 new fields ride along. +- `renderSubagentPanel`/`_renderSubagentPanelImmediate`: when `showUltracodeAgents` is on, **partition `this.subagents` into flat (no `workflowRunId`) vs grouped-by-`workflowRunId`**. Flat agents render exactly as today. For each group: build the header from `this.workflowRuns.get(runId)` (`workflowName` + phase/status chip from `phases[]`), then render that run's agents reusing the existing per-agent row markup. **Per-agent label/phase/agentType come from the JOIN** — build `Map(agentId → entry)` from `this.workflowRuns.get(runId).agents` and look each agent up by `agent.agentId`; render the small chip via the `getTeammateBadgeHtml` pattern. (If the run object hasn't arrived yet, fall back to the agent's own `description` — the run `:updated` event will fill it in on the next render.) +- `findParentSessionForSubagent` is unchanged — workflow agent `sessionId === session.claudeSessionId`. **Do not conflate `workflowRunId` with `sessionId`.** + +### 4d. `src/web/public/subagent-windows.js` +**Decision: REUSE `.subagent-window` per agent + a group sub-header — do NOT build a cluster class.** A cluster path duplicates Map/z-index/drag/cleanup/persistence for no functional gain; reuse keeps connection lines, minimize-to-tab, and `localStorage` persistence. In `openSubagentWindow`, where the optional `.subagent-window-parent` sub-header is built: when `agent.workflowRunId` is set, inject a `.subagent-workflow-header` showing `this.workflowRuns.get(runId)?.workflowName` + the joined agent's `label`/phase (look up by `agentId`), mirroring the `from ` sub-header. Respect the existing skip guards (teammate-terminal windows, minimized/`_lazyTerminal`). + +**Do NOT auto-open windows** for workflow agents — a multi-phase run can spawn many, against the 50-window/60fps budget + `MAX_TRACKED_AGENTS=500`. They render collapsed in the grouped panel; the user expands via the existing panel buttons. + +### 4e. `src/web/public/settings-ui.js` + `index.html` +- `index.html` Panels block: add a `settings-item` checkbox `id="appSettingsShowUltracodeAgents"` ("Show ULTRACODE / Workflow Agents"). +- `openAppSettings`: load `settings.showUltracodeAgents` with `false` fallback (mirror `showPlanUsageLimits`). +- `saveAppSettings`: collect `showUltracodeAgents` into the fresh settings literal (uncollected keys reset to default every save). +- Live-apply on toggle: re-run `renderSubagentPanel()` (show/hide group sections) — a panel re-render, not a CSS-class strip. +- **SYNCED, not per-device:** do NOT add `showUltracodeAgents` to `displayKeys` and do NOT strip it in the per-device block. A synced value gives the server-side gate (`isWorkflowAgentTrackingEnabled`, §3e) one canonical truth to decide whether to run the watcher; a per-device value can't gate a process-wide watcher. (Contrast `showResponseViewer`, pure client display.) +- `styles.css` + `mobile.css`: add `.subagent-workflow-header` and `.subagent-group-badge` next to `.subagent-window-parent`; mirror device overrides in `mobile.css`. + +## 5. Settings / opt-in wiring + +- **Key:** `showUltracodeAgents` (boolean, **default OFF**). Fallback `false` in `openAppSettings`; "absent ⇒ off" in `isWorkflowAgentTrackingEnabled()`. Schema `z.boolean().optional()` in the `.strict()` update schema, kept OUT of `displayKeys` (synced). +- **Runtime gating:** `workflowRunWatcher.start()` and the subagent-watcher `workflows/` descent run only when the boot-time `settings.json` read reports `showUltracodeAgents === true` (mirroring `isSubagentTrackingEnabled`). The frontend additionally gates display. Toggling at runtime gates **display** immediately (panel re-render); the **watcher branch** picks up on next boot — matches existing `subagentTrackingEnabled` semantics. (Optional polish: restart just the workflow watcher on toggle for instant on/off.) + +## 6. SSE events + +**Reused (no change):** `subagent:discovered|updated|tool_call|tool_result|progress|message|completed`. Workflow agents flow through these; payloads now carry the optional `isWorkflowAgent`/`workflowRunId` fields on `SubagentInfo`. SSE payloads aren't schema-gated (typed only at `broadcast()` call sites), so the new fields propagate with zero friction. + +**New (3 events, run-level metadata):** + +| Event (backend const / frontend key) | Payload | +|---|---| +| `workflow:run_discovered` / `WORKFLOW_RUN_DISCOVERED` | `WorkflowRunInfo` | +| `workflow:run_updated` / `WORKFLOW_RUN_UPDATED` | `WorkflowRunInfo` | +| `workflow:run_removed` / `WORKFLOW_RUN_REMOVED` | `{ runId: string }` | + +Sync requirement (CLAUDE.md): each must appear in **both** `sse-events.ts` (§3d) and `constants.js` `SSE_EVENTS` (§4a), be emitted via `broadcast()` in `setupWorkflowRunWatcherListeners()` (§3e), and have a dispatch-table row + `_on*` handler (§4b/§4c). + +## 7. Edge cases & cleanup + +- **`journal.jsonl` phantom-agent trap** — owned by §3a-4: run-dir registration requires the `agent-` prefix and excludes `journal.jsonl`. +- **`isInternalAgent` over-filtering** — owned by §3a-6: bypass at BOTH drop sites; titled from the frontend join (or the description fallback). +- **No workflow agents in the flat list** — `renderSubagentPanel` partitions on `agent.workflowRunId` (§4c). When the toggle is OFF, the descent never ran, so they aren't in `this.subagents` at all. +- **Completion/idle** — keep the existing per-agent mtime/PID/pgrep liveness as the per-card source of truth. Optionally render a group-level "workflow done" badge from run-state `status==='completed'`. +- **Limits** — `MAX_TRACKED_AGENTS=500` LRU-evicts workflow agents in the same flat map; no auto-open (50-window budget); the 4h `STARTUP_MAX_FILE_AGE_MS` skip means a run completed >4h ago won't reload after restart (acceptable — live monitoring). +- **Reconnect/replay** — agents via `getRecentSubagents(15)`; headers via `workflowRuns: getRecentRuns(15)` in `getLightState`. `handleInit` clears `this.workflowRuns` alongside the subagent Maps. +- **Watcher teardown** — every per-run `fs.watch` and the chokidar watcher closes in `stop()` and on `error`; `CleanupManager` for the new watcher (24h runs create many run dirs). +- **CLAUDE.md discipline** — read-only `~/.claude/...` artifacts; no new `~/.codeman/...` paths, no env-var prefixes touched. Claude-mode-only by nature (external CLIs don't write workflow transcripts). + +## 8. Testing & verification + +- **Unit (pure):** + - `test/workflow-run-watcher.test.ts`: feed a scrubbed fixture `wf_.json` → assert `WorkflowRunInfo` extraction (name/summary/phases, `workflowProgress`→`agents[]` keyed by `agentId`), mtime-skip, removal-by-set-diff. + - Extend `subagent-watcher` coverage: temp `subagents/workflows/wf_X/agent-Y.jsonl` + a stray `journal.jsonl` → assert `agent-Y` registered with `isWorkflowAgent`/`workflowRunId` and `journal.jsonl` NOT registered; assert a short-description workflow agent is NOT dropped at **either** `isInternalAgent` site. +- **Route/inject (`app.inject`):** `GET /api/workflows` + `:runId` return the `ApiResponse` envelope; `GET /api/subagents` includes a tagged agent. +- **Frontend (vm-sandbox, like `test/run-mode-ui.test.ts`):** dispatch `subagent:discovered` with `workflowRunId` + `workflow:run_discovered` → assert `renderSubagentPanel` produces a group section under the workflow name with the agent inside it (label sourced from the **join**, not flat); assert order-independence (agent before run, and run before agent both resolve); assert OFF hides the section. +- **REQUIRED real end-to-end** (the always-end-to-end-test rule — the plan-usage chip shipped *dead* from a gate mismatch): on dev/beta with `showUltracodeAgents` ON, **drive a real ultracode/workflow run**, then (1) `curl …/api/workflows | jq` shows the live run with `agents[]`; (2) `curl …/api/subagents | jq '.data[]|select(.isWorkflowAgent)'` shows tagged agents; (3) watch `/api/events` for `workflow:run_discovered` + `subagent:discovered` with the workflow fields; (4) Playwright (`waitUntil:'domcontentloaded'`, wait 3–4s) asserts the grouped DOM cluster renders with the workflow-name header and live status. Verify path gates against `GET /api/sessions` `workingDir`. **Test against a LIVE run** — all at-rest runs are `completed`/`done`; `running`/`queued` states only exist mid-run. + +## 9. Phased rollout + +| Phase | Scope | Done-check | Size | +|---|---|---|---| +| **P1 — Backend discovery + tagging (gated, no UI)** | §3a (nested descent, `journal.jsonl` skip, 2 `SubagentInfo` fields, `isInternalAgent` bypass ×2) + §3f schema key + §3e gate read. No run watcher yet. | With `showUltracodeAgents` forced on, `curl /api/subagents \| jq '.data[]\|select(.isWorkflowAgent)'` lists real workflow agents during a live run; flat subagents unchanged; `tsc --noEmit` + targeted watcher test green. | S–M | +| **P2 — Run-state metadata + SSE** | §3b (`workflow-run-watcher.ts`) + §3c types + §3d/§3e (SSE, bridge, `getLightState` replay) + §3g routes. | `curl /api/workflows \| jq` returns runs with `agents[]`/`phases`; SSE emits `workflow:run_discovered`; reconnect snapshot carries `workflowRuns`. | M | +| **P3 — Frontend grouped UI** | §4a–§4d (constants, app.js state/dispatch/init, panels-ui grouped render + **agent→label join**, subagent-windows group sub-header). Reuse `.subagent-window`; no auto-open. | Playwright: live run renders a group section under the workflow name with per-agent rows + live status + joined labels; flat subagents stay flat; expand opens a window with the workflow sub-header. | M | +| **P4 — Settings toggle + polish + docs** | §4e (checkbox, settings-ui load/save/live-apply, SYNCED), styles/mobile, phase chips, CLAUDE.md "Key Patterns" entry + this doc's status → SHIPPED. | Toggling the checkbox shows/hides the cluster live (no reload for display); OFF by default on a fresh install; CI green. | S | + +Each phase is independently shippable: P1 is invisible (gated, no UI), P2 adds an API with no UI dependency, P3 lights up the UI for flag-enablers, P4 exposes the toggle and finalizes defaults/docs. + +## 10. Effort & risk + +**Size:** P1 = S–M, P2 = M, P3 = M, P4 = S. Total ≈ **M** (one focused engineer, ~2–4 days incl. the real end-to-end run — down from the first draft's M-L now that the backend join/coupling is gone). + +**Top 3 risks:** + +1. **Non-recursive watch on Linux misses live writes.** `fs.watch` is non-recursive and `{recursive:true}` is unreliable on Linux → per-`wf_` watchers (§3a-4) are correct, but the 5s full scan must re-`readdir(wfRoot)` to catch *new* run dirs mid-session, and each watcher must be torn down to avoid FSWatcher leaks in 24h runs. Mitigation: explicit per-run-dir registration + verified `dirWatchers` teardown; chokidar (with `CleanupManager`) only in the new run watcher, where `team-watcher` already proves the pattern. +2. **Discovery cost / over-registration.** A user with hundreds of historical workflow agents could flood `agentInfo` on boot. Mitigation: the 4h `STARTUP_MAX_FILE_AGE_MS` skip drops old files on the initial scan, the descent only runs when the toggle is on, and `MAX_TRACKED_AGENTS=500` LRU-evicts. Verify boot scan time doesn't regress with the corpus present. +3. **Shipping-dead-on-a-gate** (the repo's recurring failure mode — the plan-usage chip shipped dead because injection was gated on `CASES_DIR` while real sessions ran elsewhere). Same trap here if the path/mode gate is wrong (e.g. `projects/*/workflows` instead of `projects/*/*/workflows`, or correlation via the wrong session key). Mitigation: the **mandatory live ultracode end-to-end run** in §8 against a real session's `workingDir`, observing the real SSE event + real DOM cluster — not the at-rest corpus, not unit tests alone. diff --git a/src/config/workflow-config.ts b/src/config/workflow-config.ts new file mode 100644 index 00000000..984270f7 --- /dev/null +++ b/src/config/workflow-config.ts @@ -0,0 +1,26 @@ +/** + * @fileoverview Workflow (ultracode) run-watcher polling and cache configuration. + * + * Controls how frequently WorkflowRunWatcher polls + * ~/.claude/projects///workflows/wf_*.json + * and how many runs are cached in memory. + * + * Distinct from the Agent-Teams config (team-config.ts). The run-state JSON is + * rewritten on every agent tick across a whole run (28+ agents), so the watcher + * relies on a per-file mtime skip; the poll itself is just N stat() calls. + * + * @module config/workflow-config + */ + +/** Workflow run-state poll interval (ms). Short because a poll is just N mtime stats. */ +export const WORKFLOW_RUN_POLL_INTERVAL_MS = 10_000; + +/** Max cached workflow runs (LRU eviction). */ +export const MAX_CACHED_WORKFLOW_RUNS = 100; + +/** + * Default recency window (minutes) for getRecentRuns(). Generous enough that a + * recently-finished long run still appears in the LEFT-pane list — filtered on + * last-activity, not start time, so multi-hour runs don't vanish. + */ +export const WORKFLOW_RUN_RECENT_WINDOW_MIN = 240; diff --git a/src/types/index.ts b/src/types/index.ts index 22a7369c..7624f89b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -67,3 +67,4 @@ export * from './push.js'; export * from './plan.js'; export * from './orchestrator.js'; export * from './update.js'; +export * from './workflow-run.js'; diff --git a/src/types/workflow-run.ts b/src/types/workflow-run.ts new file mode 100644 index 00000000..be37e2f8 --- /dev/null +++ b/src/types/workflow-run.ts @@ -0,0 +1,135 @@ +/** + * @fileoverview Types for ultracode / Workflow-tool run visualization. + * + * A Workflow run persists its state to + * `~/.claude/projects///workflows/wf_.json` + * (a sibling of the deeper `subagents/workflows/wf_/agent-*.jsonl` + * transcript tree that subagent-watcher tracks). This file is the single source + * for the master-detail "working agents" view: a run's tasks/phases on the LEFT + * and per-agent stats (tokens burned, tool calls) on the RIGHT. + * + * Field presence is STATE-DRIVEN and verified against real runs on disk: + * - state 'start' (queued): no agentId/tokens/toolCalls/startedAt/durationMs/... + * - state 'progress' (running): has agentId/tokens/toolCalls, no durationMs/resultPreview + * - state 'done' (finished): all fields, incl. durationMs/resultPreview + * Absent fields are genuinely ABSENT (never explicit null) — use `?:`, not null. + * + * @module types/workflow-run + */ + +/** One declared phase of a run (from the run JSON's top-level `phases[]`, 0-indexed). */ +export interface WorkflowRunPhase { + /** Phase title; equals each member agent's `phaseTitle`. Always present. */ + title: string; + /** Human description of the phase. Always present in `phases[]`. */ + detail: string; +} + +/** + * One agent slot in a run, derived from `workflowProgress[]` entries where + * `type === 'workflow_agent'`. Optional fields are absent until the agent + * reaches the relevant lifecycle state (see module doc). + */ +export interface WorkflowAgentInfo { + /** 1-based stable slot index, unique within the run. Always present. */ + index: number; + /** Agent label, e.g. "probe:dompurify-config". Always present. */ + label: string; + /** 1-based phase number; join via `run.phases[phaseIndex - 1]`. Always present. */ + phaseIndex: number; + /** Phase title (=== run.phases[phaseIndex-1].title). Always present. */ + phaseTitle: string; + /** Model id, e.g. "claude-opus-4-8[1m]". Always present. */ + model: string; + /** Lifecycle state. Real on-disk values: 'start' | 'progress' | 'done'. Open union. */ + state: 'start' | 'progress' | 'done' | (string & {}); + /** Epoch ms the slot was queued. Always present. */ + queuedAt?: number; + /** Epoch ms of the last progress tick. Always present once any progress occurs. */ + lastProgressAt?: number; + /** Truncated prompt the agent was given. Always present. */ + promptPreview?: string; + /** + * Globally-unique agent id; equals the `agent-.jsonl` transcript stem + * (the Phase-4 correlation key). ABSENT while state === 'start'. + */ + agentId?: string; + /** Epoch ms the agent began. Absent while 'start'. */ + startedAt?: number; + /** Attempt counter. Absent while 'start'. */ + attempt?: number; + /** Tokens burned so far (RIGHT pane). Absent while 'start'. */ + tokens?: number; + /** Tool calls made so far (RIGHT pane). Absent while 'start'. */ + toolCalls?: number; + /** Name of the most recent tool. Present for progress/done (occasionally absent). */ + lastToolName?: string; + /** Short summary of the most recent tool call. May be absent even when 'done'. */ + lastToolSummary?: string; + /** Total run time (ms). Present ONLY when 'done' — the live-vs-finished discriminator. */ + durationMs?: number; + /** Truncated final result. Present ONLY when 'done'. */ + resultPreview?: string; +} + +/** + * Run-level info shipped to the browser. + * + * IMPORTANT: the on-disk JSON also carries `script` (15–660KB of embedded JS), + * `scriptPath`, `result`, and `logs`. The watcher STRIPS all four before the + * object is ever cached/broadcast — never let them reach SSE/getLightState/route. + */ +export interface WorkflowRunInfo { + /** Run id (=== the wf_.json filename stem). Always present. */ + runId: string; + /** Workflow name from `meta.name`. Always present. */ + workflowName?: string; + /** + * Run status. Real on-disk values seen: 'completed' | 'killed'. + * 'running' | 'failed' are inferred (parse defensively; keep open union). + */ + status?: 'completed' | 'killed' | 'running' | 'failed' | (string & {}); + /** Concise human description (best LEFT-pane label). Always present. */ + summary?: string; + /** Total agent slots, INCLUDING not-yet-started 'start' agents. */ + agentCount?: number; + /** Total tokens across the run (partial mid-run). */ + totalTokens?: number; + /** Total tool calls across the run (partial mid-run). */ + totalToolCalls?: number; + /** Total run duration (ms). */ + durationMs?: number; + /** Run start time (epoch MILLIS). */ + startTime?: number; + /** ISO end/write timestamp. */ + timestamp?: string; + /** Default model for the run. */ + defaultModel?: string; + /** Background-task id that owns the run. */ + taskId?: string; + /** Declared phases (0-indexed). */ + phases: WorkflowRunPhase[]; + /** Agents, derived from `workflowProgress` filtered to `type === 'workflow_agent'`. */ + agents: WorkflowAgentInfo[]; + /** Error message, present when status is 'killed'/'failed'. */ + error?: string; + + // ----- Watcher-derived (NOT in the JSON body — captured from the file path) ----- + /** `` path segment (for per-session scoping). */ + sessionUuid: string; + /** `` path segment. */ + projectHash: string; + /** + * Most recent activity (epoch ms): max agent `lastProgressAt`, else `startTime`. + * Drives recency filtering/sorting so finished long runs still surface. + */ + lastActivityAt: number; +} + +/** + * Lightweight run projection (no `agents[]`) for the LEFT-pane list and the + * getLightState reconnect snapshot. A full run with 28 agents serializes to + * ~36KB; the snapshot ships dozens of runs, so it carries summaries only and the + * RIGHT pane fetches the full run (`GET /api/workflows/:runId`) on selection. + */ +export type WorkflowRunSummary = Omit; diff --git a/src/web/public/app.js b/src/web/public/app.js index fc7e5278..76d6befb 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -221,6 +221,11 @@ const _SSE_HANDLER_MAP = [ [SSE_EVENTS.SUBAGENT_TOOL_RESULT, '_onSubagentToolResult'], [SSE_EVENTS.SUBAGENT_COMPLETED, '_onSubagentCompleted'], + // Workflow runs (ultracode) + [SSE_EVENTS.WORKFLOW_RUN_DISCOVERED, '_onWorkflowRunDiscovered'], + [SSE_EVENTS.WORKFLOW_RUN_UPDATED, '_onWorkflowRunUpdated'], + [SSE_EVENTS.WORKFLOW_RUN_REMOVED, '_onWorkflowRunRemoved'], + // Images [SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'], [SSE_EVENTS.ATTACHMENT_DETECTED, '_onAttachmentDetected'], @@ -339,6 +344,12 @@ class CodemanApp { this.subagentToolResults = new Map(); // Map> - tool results by toolUseId this.activeSubagentId = null; // Currently selected subagent for detail view this.subagentPanelVisible = false; + + // Ultracode / Workflow run visualization (master-detail tab) + this.workflowRuns = new Map(); // runId -> run summary (LEFT list) + this.workflowRunDetails = new Map(); // runId -> full run with agents[] (RIGHT pane) + this.activeWorkflowRunId = null; + this.activeWorkflowPhaseIndex = null; this.subagentWindows = new Map(); // Map this.subagentWindowZIndex = ZINDEX_SUBAGENT_BASE; this.minimizedSubagents = new Map(); // Map> - minimized to tab @@ -2228,6 +2239,11 @@ class CodemanApp { // Clear subagent activity/results maps (prevents leaks if data.subagents is missing) this.subagentActivity.clear(); this.subagentToolResults.clear(); + // Clear ultracode workflow run state (re-seeded from data.workflowRuns below) + if (this.workflowRuns) this.workflowRuns.clear(); + if (this.workflowRunDetails) this.workflowRunDetails.clear(); + this.activeWorkflowRunId = null; + this.activeWorkflowPhaseIndex = null; // Clean up mobile/keyboard handlers and re-init (prevents listener accumulation on reconnect) MobileDetection.cleanup(); KeyboardHandler.cleanup(); @@ -2402,6 +2418,11 @@ class CodemanApp { }); } + // Seed ultracode workflow runs (LEFT-pane summaries) from the snapshot + if (data.workflowRuns) { + this.seedWorkflowRuns(data.workflowRuns); + } + // Restore previously active session (survives page reload + SSE reconnect) // Must always re-select because handleInit clears terminal state above. // Reset activeSessionId so selectSession doesn't early-return. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 379b97c3..33b74246 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -350,6 +350,11 @@ const SSE_EVENTS = { SUBAGENT_TOOL_RESULT: 'subagent:tool_result', SUBAGENT_COMPLETED: 'subagent:completed', + // Workflow runs (ultracode / Workflow tool) + WORKFLOW_RUN_DISCOVERED: 'workflow:run_discovered', + WORKFLOW_RUN_UPDATED: 'workflow:run_updated', + WORKFLOW_RUN_REMOVED: 'workflow:run_removed', + // Images IMAGE_DETECTED: 'image:detected', ATTACHMENT_DETECTED: 'attachment:detected', diff --git a/src/web/public/index.html b/src/web/public/index.html index 90526869..c41aea48 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -122,6 +122,7 @@ +
—
+ + + +
+
+
+
No ultracode runs detected
+
+
+
Select a run to view its agents
+
+
+
+ + +
+ Ultracode Agents + +
Subagent Options
@@ -1927,6 +1958,7 @@ + diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index a4ceb53f..2dd58764 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -313,6 +313,7 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false; document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false; document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false; + document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false; document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false; document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false; // Gesture control lives in the Input section (alongside Local Echo / CJK Input) @@ -1368,6 +1369,7 @@ Object.assign(CodemanApp.prototype, { showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked, showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked, showSubagents: document.getElementById('appSettingsShowSubagents').checked, + showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked, showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked, showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked, gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked, @@ -1682,6 +1684,7 @@ Object.assign(CodemanApp.prototype, { showProjectInsights: false, showFileBrowser: false, showSubagents: false, + showUltracodeAgents: false, showMultiMonitorButton: false, showPlanUsageLimits: false, showAttachmentsButton: false, @@ -1805,6 +1808,15 @@ Object.assign(CodemanApp.prototype, { multiMonitorBtn.classList.toggle('btn-multimonitor--hidden', !showMultiMonitorButton); } + // Ultracode/Workflow agents launcher — hidden by default; reveal when enabled. + // Marker class only (base is display:inline-flex !important) so it's auto-excluded + // from the mobile-header-buttons-policy guard. + const showUltracodeAgents = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false; + const ultracodeBtn = document.querySelector('.btn-ultracode-agents'); + if (ultracodeBtn) { + ultracodeBtn.classList.toggle('btn-ultracode-agents--hidden', !showUltracodeAgents); + } + // Plan-usage chip — hidden by default (App Settings → Display → "Plan Usage // Limits"). Server renders the initial state on reload; this handles a live // toggle from a settings save. Marker class (base is display:inline-flex @@ -1876,6 +1888,18 @@ Object.assign(CodemanApp.prototype, { } } + // Ultracode agents panel visibility (SYNCED setting — not in displayKeys) + const showUltracodeAgents = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false; + const ultracodePanel = document.getElementById('ultracodeAgentsPanel'); + if (ultracodePanel) { + if (showUltracodeAgents) { + ultracodePanel.classList.remove('hidden'); + } else { + ultracodePanel.classList.remove('open'); + ultracodePanel.classList.add('hidden'); + } + } + // File browser panel visibility const fileBrowserPanel = document.getElementById('fileBrowserPanel'); if (fileBrowserPanel) { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 4b4ecf98..7076386a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -8355,6 +8355,200 @@ kbd { display: none !important; } +/* "Ultracode Agents" header launcher — opt-in (App Settings → Display), hidden by + default everywhere (so the mobile-header-buttons-policy guard auto-excludes it). + Base inline-flex !important + a more-specific marker rule to hide. */ +.btn-ultracode-agents { + display: inline-flex !important; +} +.btn-ultracode-agents.btn-ultracode-agents--hidden { + display: none !important; +} + +/* ===== Ultracode / Workflow agents master-detail panel ===== */ +/* Reuses .subagents-panel dock behavior but anchors LEFT so it never overlaps the + bottom-right subagents panel when both are open. */ +.ultracode-agents-panel { + right: auto; + left: 0.5rem; + width: 640px; +} +@media (max-width: 700px) { + .ultracode-agents-panel { + left: 0; + right: 0; + width: 100%; + max-width: 100%; + } +} + +/* LEFT pane: run rows */ +.ultracode-run-item { + padding: 0.45rem 0.5rem; + border-radius: 6px; + background: var(--bg-input); + margin-bottom: 0.4rem; + cursor: pointer; + transition: background 0.15s; +} +.ultracode-run-item:hover { + background: var(--bg-card); +} +.ultracode-run-item.selected { + background: var(--bg-card); + border: 1px solid var(--accent); +} +.ultracode-run-head { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.2rem; +} +.ultracode-run-name { + font-size: 0.75rem; + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ultracode-run-stats { + font-size: 0.65rem; + color: var(--text-dim); +} +.ultracode-status { + font-size: 0.58rem; + padding: 0.05rem 0.3rem; + border-radius: 4px; + margin-left: auto; + text-transform: lowercase; + background: var(--text-muted); + color: white; + flex-shrink: 0; +} +.ultracode-status.completed { + background: var(--success); +} +.ultracode-status.active { + background: var(--warning); + color: black; +} +.ultracode-status.failed { + background: var(--error, #b3261e); + color: white; +} + +/* LEFT pane: phase chips for the selected run */ +.ultracode-phase-list { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.4rem; +} +.ultracode-phase-chip { + font-size: 0.62rem; + padding: 0.1rem 0.4rem; + border-radius: 10px; + background: var(--bg-card); + border: 1px solid var(--border); + color: var(--text-muted); + cursor: pointer; +} +.ultracode-phase-chip:hover { + color: var(--text); +} +.ultracode-phase-chip.selected { + background: var(--accent); + border-color: var(--accent); + color: white; +} + +/* RIGHT pane: per-phase header + agent cards */ +.ultracode-phase-header { + display: flex; + align-items: baseline; + justify-content: space-between; + font-size: 0.7rem; + font-weight: 600; + color: var(--text); + margin: 0.5rem 0 0.3rem; + padding-bottom: 0.2rem; + border-bottom: 1px solid var(--border); +} +.ultracode-phase-sub { + font-size: 0.62rem; + font-weight: 400; + color: var(--text-dim); +} +.ultracode-agent-card { + background: var(--bg-input); + border-radius: 6px; + padding: 0.4rem 0.5rem; + margin-bottom: 0.35rem; +} +.ultracode-agent-top { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.25rem; +} +.ultracode-agent-label { + font-size: 0.72rem; + font-weight: 600; + color: var(--text); + font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ultracode-agent-state { + font-size: 0.56rem; + padding: 0.05rem 0.3rem; + border-radius: 4px; + margin-left: auto; + flex-shrink: 0; + background: var(--text-muted); + color: white; +} +.ultracode-agent-state.completed { + background: var(--success); +} +.ultracode-agent-state.active { + background: var(--warning); + color: black; +} +.ultracode-agent-state.idle { + background: var(--bg-card); + color: var(--text-muted); +} +.ultracode-agent-meta { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} +.ultracode-chip { + font-size: 0.62rem; + padding: 0.05rem 0.35rem; + border-radius: 4px; + background: var(--bg-card); + color: var(--text-muted); + font-family: var(--font-mono); +} +.ultracode-chip-tok { + color: var(--accent); +} +.ultracode-chip-tool { + color: var(--text); +} +.ultracode-agent-sub { + font-size: 0.62rem; + color: var(--text-dim); + margin-top: 0.25rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the live 5-hour + weekly plan limits parsed from the Claude statusline. Hidden by default via the marker class below; the server strips it at render when the diff --git a/src/web/public/ultracode-panel.js b/src/web/public/ultracode-panel.js new file mode 100644 index 00000000..76f89e54 --- /dev/null +++ b/src/web/public/ultracode-panel.js @@ -0,0 +1,263 @@ +/** + * @fileoverview Ultracode / Workflow run visualization — master-detail dock panel. + * + * Mirrors Claude Code's "working agents" TUI: LEFT pane = runs and their phases + * (selectable "tasks"), RIGHT pane = the selected run's agents with model, live + * state, TOKENS burned, and TOOL CALLS. Opt-in via the `showUltracodeAgents` + * setting; the launcher button + panel are hidden until enabled. + * + * Data: run SUMMARIES arrive via getLightState (`data.workflowRuns`) and the + * `workflow:run_*` SSE events (LEFT list). The full run (with agents[]) is fetched + * per-run from GET /api/workflows/:runId when a run is selected (RIGHT pane). + * + * Standalone: reads only the workflow-run endpoints; never touches subagent state. + * + * @mixin Extends CodemanApp.prototype via Object.assign + * @loadorder 11.5 (after panels-ui.js, before session-ui.js) + */ +/* global CodemanApp, SSE_EVENTS, escapeHtml */ + +Object.assign(CodemanApp.prototype, { + /** Ensure workflow state maps exist (lazy — constructor also seeds them). */ + _ensureWorkflowState() { + if (!this.workflowRuns) this.workflowRuns = new Map(); // runId -> summary + if (!this.workflowRunDetails) this.workflowRunDetails = new Map(); // runId -> full run (with agents) + if (this.activeWorkflowRunId === undefined) this.activeWorkflowRunId = null; + if (this.activeWorkflowPhaseIndex === undefined) this.activeWorkflowPhaseIndex = null; + }, + + /** Seed the LEFT list from a getLightState snapshot (array of run summaries). */ + seedWorkflowRuns(summaries) { + this._ensureWorkflowState(); + this.workflowRuns.clear(); + (summaries || []).forEach((s) => this.workflowRuns.set(s.runId, s)); + this.renderUltracodeAgentsPanel(); + }, + + // ----- SSE handlers (wired in app.js _SSE_HANDLER_MAP) ----- + _onWorkflowRunDiscovered(data) { + this._upsertWorkflowRun(data); + }, + _onWorkflowRunUpdated(data) { + this._upsertWorkflowRun(data); + }, + _onWorkflowRunRemoved(data) { + this._ensureWorkflowState(); + if (!data || !data.runId) return; + this.workflowRuns.delete(data.runId); + this.workflowRunDetails.delete(data.runId); + if (this.activeWorkflowRunId === data.runId) this.activeWorkflowRunId = null; + this.renderUltracodeAgentsPanel(); + }, + + _upsertWorkflowRun(summary) { + this._ensureWorkflowState(); + if (!summary || !summary.runId) return; + this.workflowRuns.set(summary.runId, summary); + // If the live-updating run is the one open in the detail pane, refresh its agents. + if (this.activeWorkflowRunId === summary.runId) { + this._fetchWorkflowRunDetail(summary.runId); + } + this.renderUltracodeAgentsPanel(); + }, + + // ----- Panel open/close ----- + toggleUltracodeAgentsPanel() { + const panel = document.getElementById('ultracodeAgentsPanel'); + if (!panel) return; + panel.classList.remove('hidden'); + panel.classList.toggle('open'); + if (panel.classList.contains('open')) this.renderUltracodeAgentsPanel(); + }, + closeUltracodeAgentsPanel() { + const panel = document.getElementById('ultracodeAgentsPanel'); + if (panel) panel.classList.remove('open'); + }, + + // ----- Selection ----- + selectWorkflowRun(runId) { + this._ensureWorkflowState(); + this.activeWorkflowRunId = runId; + this.activeWorkflowPhaseIndex = null; // reset phase filter on run change + this._fetchWorkflowRunDetail(runId); + this.renderUltracodeAgentsPanel(); + }, + selectWorkflowPhase(phaseIndex) { + this._ensureWorkflowState(); + // phaseIndex null => show all phases + this.activeWorkflowPhaseIndex = phaseIndex === null || phaseIndex === undefined ? null : Number(phaseIndex); + this._renderUltracodeDetail(); + }, + + async _fetchWorkflowRunDetail(runId) { + try { + const res = await fetch(`/api/workflows/${encodeURIComponent(runId)}`); + const env = await res.json(); + const run = env && env.success ? env.data : null; + if (run) { + this.workflowRunDetails.set(runId, run); + if (this.activeWorkflowRunId === runId) this._renderUltracodeDetail(); + } + } catch { + /* transient — next update retries */ + } + }, + + // ----- Render (debounced) ----- + renderUltracodeAgentsPanel() { + clearTimeout(this._ultracodeRenderTimer); + this._ultracodeRenderTimer = setTimeout(() => this._renderUltracodeAgentsPanelImmediate(), 150); + }, + + _renderUltracodeAgentsPanelImmediate() { + this._ensureWorkflowState(); + const panel = document.getElementById('ultracodeAgentsPanel'); + if (!panel) return; + const badge = document.getElementById('ultracodeCountBadge'); + if (badge) badge.textContent = this.workflowRuns.size ? String(this.workflowRuns.size) : ''; + this._renderUltracodeRunList(); + this._renderUltracodeDetail(); + }, + + _renderUltracodeRunList() { + const list = document.getElementById('ultracodeRunList'); + if (!list) return; + const runs = Array.from(this.workflowRuns.values()).sort( + (a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0) + ); + if (!runs.length) { + list.innerHTML = '
No ultracode runs detected
'; + return; + } + list.innerHTML = runs.map((r) => this._workflowRunRowHtml(r)).join(''); + }, + + _workflowRunRowHtml(r) { + const active = r.runId === this.activeWorkflowRunId; + const name = escapeHtml(r.workflowName || r.summary || r.runId); + const status = String(r.status || ''); + const statusCls = this._workflowStatusClass(status); + const stats = `${r.agentCount ?? 0} agents · ${this._fmtNum(r.totalTokens)} tok · ${r.totalToolCalls ?? 0} tools`; + let phasesHtml = ''; + if (active && Array.isArray(r.phases) && r.phases.length) { + const allActive = this.activeWorkflowPhaseIndex === null ? ' selected' : ''; + const chips = [ + `
All
`, + ]; + r.phases.forEach((p, i) => { + const sel = this.activeWorkflowPhaseIndex === i + 1 ? ' selected' : ''; + chips.push( + `
${escapeHtml(p.title || 'Phase ' + (i + 1))}
` + ); + }); + phasesHtml = `
${chips.join('')}
`; + } + return ( + `
` + + `
${name}` + + `${escapeHtml(status || '—')}
` + + `
${escapeHtml(stats)}
` + + phasesHtml + + `
` + ); + }, + + _renderUltracodeDetail() { + const detail = document.getElementById('ultracodeAgentGrid'); + if (!detail) return; + const runId = this.activeWorkflowRunId; + if (!runId) { + detail.innerHTML = '
Select a run to view its agents
'; + return; + } + const run = this.workflowRunDetails.get(runId); + if (!run) { + detail.innerHTML = '
Loading agents…
'; + return; + } + const phases = Array.isArray(run.phases) ? run.phases : []; + let agents = Array.isArray(run.agents) ? run.agents : []; + if (this.activeWorkflowPhaseIndex !== null) { + agents = agents.filter((a) => a.phaseIndex === this.activeWorkflowPhaseIndex); + } + if (!agents.length) { + detail.innerHTML = '
No agents in this view
'; + return; + } + // Group agents by phaseIndex, in phase order. + const groups = new Map(); + agents.forEach((a) => { + const key = a.phaseIndex || 0; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(a); + }); + const orderedKeys = Array.from(groups.keys()).sort((a, b) => a - b); + const html = orderedKeys + .map((key) => { + const group = groups.get(key); + const title = (phases[key - 1] && phases[key - 1].title) || `Phase ${key}`; + const tok = group.reduce((s, a) => s + (a.tokens || 0), 0); + const tools = group.reduce((s, a) => s + (a.toolCalls || 0), 0); + const header = + `
${escapeHtml(title)}` + + `${this._fmtNum(tok)} tok · ${tools} tools
`; + return header + group.map((a) => this._workflowAgentCardHtml(a)).join(''); + }) + .join(''); + detail.innerHTML = html; + }, + + _workflowAgentCardHtml(a) { + const state = String(a.state || 'start'); + const stateCls = this._workflowAgentStateClass(state); + const stateLabel = state === 'start' ? 'queued' : state === 'progress' ? 'running' : state; + const model = this._modelShort(a.model); + const tokens = a.tokens === undefined ? '—' : this._fmtNum(a.tokens); + const tools = a.toolCalls === undefined ? '—' : String(a.toolCalls); + let secondary = ''; + if (state === 'done' && a.resultPreview) { + secondary = escapeHtml(a.resultPreview); + } else if (a.lastToolName) { + secondary = escapeHtml(a.lastToolName + (a.lastToolSummary ? ' · ' + a.lastToolSummary : '')); + } + return ( + `
` + + `
` + + `${escapeHtml(a.label || 'agent')}` + + `${escapeHtml(stateLabel)}` + + `
` + + `
` + + `${escapeHtml(model)}` + + `${tokens} tok` + + `${tools} tools` + + `
` + + (secondary ? `
${secondary}
` : '') + + `
` + ); + }, + + // ----- helpers ----- + _workflowStatusClass(status) { + if (status === 'completed') return 'completed'; + if (status === 'running') return 'active'; + if (status === 'killed' || status === 'failed') return 'failed'; + return ''; + }, + _workflowAgentStateClass(state) { + if (state === 'done') return 'completed'; + if (state === 'progress') return 'active'; + return 'idle'; // start / queued + }, + _modelShort(model) { + if (!model) return ''; + return String(model) + .replace(/^claude-/, '') + .replace(/-\d{8}$/, ''); + }, + _fmtNum(n) { + if (n === undefined || n === null) return '0'; + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'; + if (n >= 1000) return (n / 1000).toFixed(1) + 'k'; + return String(n); + }, +}); diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index d5bce56c..b15c8b56 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -26,6 +26,7 @@ import { } from '../schemas.js'; import { subagentWatcher } from '../../subagent-watcher.js'; import { imageWatcher } from '../../image-watcher.js'; +import { workflowRunWatcher } from '../../workflow-run-watcher.js'; import { applyStatusLineConfig } from '../../hooks-config.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; import { @@ -540,6 +541,9 @@ export function registerSystemRoutes( // Handle subagent tracking toggle dynamically toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher'); + // Handle ultracode/workflow run watcher toggle dynamically (default OFF) + toggleService((settings.showUltracodeAgents as boolean) ?? false, workflowRunWatcher, 'Workflow run watcher'); + // Handle image watcher toggle dynamically toggleService((settings.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => { // Re-watch all active sessions that have image watcher enabled @@ -691,6 +695,27 @@ export function registerSystemRoutes( } }); + // ========== Workflow Run Monitoring (ultracode) ========== + + // LEFT-pane list: lightweight run summaries (no agents[]). + app.get('/api/workflows', async (req) => { + const { minutes } = req.query as { minutes?: string }; + const runs = minutes + ? workflowRunWatcher.getRecentRunSummaries(parseInt(minutes, 10)) + : workflowRunWatcher.getAllRunSummaries(); + return { success: true, data: runs }; + }); + + // RIGHT-pane detail: full run incl. agents[] (tokens/toolCalls/state per agent). + app.get('/api/workflows/:runId', async (req) => { + const { runId } = req.params as { runId: string }; + const run = workflowRunWatcher.getRun(runId); + if (!run) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, `Workflow run ${runId} not found`); + } + return { success: true, data: run }; + }); + // ========== Subagent Monitoring ========== app.get('/api/subagents', async (req) => { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 6d5c848c..2b4ceaa3 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -348,6 +348,8 @@ export const SettingsUpdateSchema = z ralphTrackerEnabled: z.boolean().optional(), subagentTrackingEnabled: z.boolean().optional(), subagentActiveTabOnly: z.boolean().optional(), + /** Ultracode/Workflow run visualization (default OFF). Gates workflowRunWatcher + the master-detail tab. SYNCED. */ + showUltracodeAgents: z.boolean().optional(), imageWatcherEnabled: z.boolean().optional(), tunnelEnabled: z.boolean().optional(), tabTwoRows: z.boolean().optional(), diff --git a/src/web/server.ts b/src/web/server.ts index 8eada0d1..4d60470b 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -44,7 +44,7 @@ import { dataPath } from '../config/instance.js'; import { getHookSecret } from '../config/hook-secret.js'; import { EventEmitter } from 'node:events'; import { Session, isExternalCliMode, type BackgroundTask } from '../session.js'; -import type { ClaudeMode, SessionAttachmentHistoryItem, SessionState } from '../types.js'; +import type { ClaudeMode, SessionAttachmentHistoryItem, SessionState, WorkflowRunInfo } from '../types.js'; import { RespawnController, RespawnConfig } from '../respawn-controller.js'; import type { TerminalMultiplexer } from '../mux-interface.js'; import { createMultiplexer } from '../mux-factory.js'; @@ -60,6 +60,7 @@ import { type SubagentToolResult, } from '../subagent-watcher.js'; import { imageWatcher } from '../image-watcher.js'; +import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js'; import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js'; import { buildDetectedAttachmentHistoryItem, @@ -259,6 +260,11 @@ export class WebServer extends EventEmitter { attachmentDetected: (event: AttachmentDetectedEvent) => void; error: (error: Error, sessionId?: string) => void; } | null = null; + private workflowRunWatcherHandlers: { + discovered: (info: WorkflowRunInfo) => void; + updated: (info: WorkflowRunInfo) => void; + removed: (data: { runId: string }) => void; + } | null = null; private tunnelManager: TunnelManager = new TunnelManager(); private authSessions: StaleExpirationMap | null = null; private authFailures: StaleExpirationMap | null = null; @@ -338,6 +344,7 @@ export class WebServer extends EventEmitter { // Set up subagent watcher listeners this.setupSubagentWatcherListeners(); + this.setupWorkflowRunWatcherListeners(); // Set up image watcher listeners this.setupImageWatcherListeners(); @@ -437,6 +444,31 @@ export class WebServer extends EventEmitter { } } + /** + * Bridge WorkflowRunWatcher events → SSE. Broadcasts run SUMMARIES (no agents[]) + * to keep payloads small; the full agents[] is fetched per-run via + * GET /api/workflows/:runId when the user selects a run. + */ + private setupWorkflowRunWatcherListeners(): void { + this.workflowRunWatcherHandlers = { + discovered: (info: WorkflowRunInfo) => this.broadcast(SseEvent.WorkflowRunDiscovered, summarizeRun(info)), + updated: (info: WorkflowRunInfo) => this.broadcast(SseEvent.WorkflowRunUpdated, summarizeRun(info)), + removed: (data: { runId: string }) => this.broadcast(SseEvent.WorkflowRunRemoved, data), + }; + workflowRunWatcher.on('run_discovered', this.workflowRunWatcherHandlers.discovered); + workflowRunWatcher.on('run_updated', this.workflowRunWatcherHandlers.updated); + workflowRunWatcher.on('run_removed', this.workflowRunWatcherHandlers.removed); + } + + private cleanupWorkflowRunWatcherListeners(): void { + if (this.workflowRunWatcherHandlers) { + workflowRunWatcher.off('run_discovered', this.workflowRunWatcherHandlers.discovered); + workflowRunWatcher.off('run_updated', this.workflowRunWatcherHandlers.updated); + workflowRunWatcher.off('run_removed', this.workflowRunWatcherHandlers.removed); + this.workflowRunWatcherHandlers = null; + } + } + /** * Set up event listeners for image watcher. * Broadcasts image detection events to SSE clients for auto-popup. @@ -1669,6 +1701,7 @@ export class WebServer extends EventEmitter { respawnStatus, globalStats: this.store.getAggregateStats(activeSessionTokens), subagents: subagentWatcher.getRecentSubagents(15), // 15 min to avoid stale agents + workflowRuns: workflowRunWatcher.getAllRunSummaries(), // ultracode run summaries (no agents[]) for the LEFT list timestamp: now, inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON', planUsage: getLatestPlanUsage(), // last-known plan-usage telemetry, for the header chip on fresh load @@ -1937,6 +1970,14 @@ export class WebServer extends EventEmitter { console.log('Subagent watcher disabled by user settings'); } + // Start workflow run watcher for ultracode / Workflow run visualization (if enabled) + if (await this.isWorkflowAgentTrackingEnabled()) { + workflowRunWatcher.start(); + console.log('Workflow run watcher started - monitoring ~/.claude/projects for ultracode run activity'); + } else { + console.log('Workflow run watcher disabled by user settings (showUltracodeAgents off)'); + } + // Start image watcher for auto-popup of screenshots (if enabled) if (await this.isImageWatcherEnabled()) { imageWatcher.start(); @@ -1983,6 +2024,23 @@ export class WebServer extends EventEmitter { return true; // Default enabled } + /** + * Check if ultracode/workflow run tracking is enabled in settings (default: FALSE — opt-in). + */ + private async isWorkflowAgentTrackingEnabled(): Promise { + const settingsPath = dataPath('settings.json'); + try { + const content = await fs.readFile(settingsPath, 'utf-8'); + const settings = JSON.parse(content); + return settings.showUltracodeAgents ?? false; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('Failed to read showUltracodeAgents setting:', err); + } + } + return false; // Default disabled (opt-in) + } + /** * Check if image watcher is enabled in settings (default: false) */ @@ -2337,12 +2395,16 @@ export class WebServer extends EventEmitter { // Clean up watcher listeners to prevent memory leaks this.cleanupSubagentWatcherListeners(); + this.cleanupWorkflowRunWatcherListeners(); this.cleanupImageWatcherListeners(); this.cleanupTeamWatcherListeners(); // Stop subagent watcher subagentWatcher.stop(); + // Stop workflow run watcher + workflowRunWatcher.stop(); + // Stop image watcher imageWatcher.stop(); diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index f4942e4e..5d57e781 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -15,6 +15,7 @@ * - **Mux** (4): created, killed, died, statsUpdated * - **Respawn** (24): stateChanged, cycleStarted/Completed, step*, aiCheck*, planCheck*, timer*, log, ... * - **Subagents** (7): discovered, updated, tool_call, tool_result, progress, message, completed + * - **Workflow runs** (3): run_discovered, run_updated, run_removed (ultracode / Workflow tool) * - **Scheduled** (6): created, updated, completed, stopped, log, deleted * - **Teams** (4): created, updated, removed, taskUpdated * - **Transcript** (4): complete, plan_mode, tool_start, tool_end @@ -215,6 +216,15 @@ export const SubagentMessage = 'subagent:message' as const; /** Subagent finished. */ export const SubagentCompleted = 'subagent:completed' as const; +// ─── Workflow Runs (ultracode / Workflow tool) ─────────────────────────────── + +/** A workflow run was discovered (first time seen). Payload: WorkflowRunInfo. */ +export const WorkflowRunDiscovered = 'workflow:run_discovered' as const; +/** A workflow run changed (agent state/token tick). Payload: WorkflowRunInfo. */ +export const WorkflowRunUpdated = 'workflow:run_updated' as const; +/** A workflow run's file disappeared. Payload: { runId: string }. */ +export const WorkflowRunRemoved = 'workflow:run_removed' as const; + // ─── Scheduled Runs ────────────────────────────────────────────────────────── /** Scheduled run created. */ @@ -446,6 +456,11 @@ export const SseEvent = { SubagentMessage, SubagentCompleted, + // Workflow runs (ultracode) + WorkflowRunDiscovered, + WorkflowRunUpdated, + WorkflowRunRemoved, + // Scheduled runs ScheduledCreated, ScheduledUpdated, diff --git a/src/workflow-run-watcher.ts b/src/workflow-run-watcher.ts new file mode 100644 index 00000000..3b9500b1 --- /dev/null +++ b/src/workflow-run-watcher.ts @@ -0,0 +1,383 @@ +/** + * @fileoverview Workflow (ultracode) Run Watcher + * + * Watches `~/.claude/projects///workflows/wf_*.json` — + * the run-state JSON the Workflow tool writes for each ultracode run — and emits + * events powering the master-detail "working agents" view (tasks/phases on the + * LEFT, per-agent tokens/tool-calls on the RIGHT). + * + * Deliberately STANDALONE: it never imports from or touches subagent-watcher.ts. + * It globs the run-state tree (`.../workflows/wf_*.json`); subagent-watcher globs + * the disjoint, deeper transcript tree (`.../subagents/workflows/wf_/agent-*`). + * Separate singletons, disjoint directories, no shared mutable state. + * + * Discovery is dual: a periodic poll (catches new run dirs + removals) plus a + * per-run-dir chokidar watcher (live within-run updates). A per-file mtime skip + * keeps the hot path cheap — the run JSON is rewritten on every agent tick. + * + * @module workflow-run-watcher + */ + +import { EventEmitter } from 'node:events'; +import { readdir, readFile, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { watch as chokidarWatch, type FSWatcher as ChokidarWatcher } from 'chokidar'; + +import type { WorkflowRunInfo, WorkflowRunSummary, WorkflowAgentInfo, WorkflowRunPhase } from './types/workflow-run.js'; +import { LRUMap } from './utils/lru-map.js'; +import { + WORKFLOW_RUN_POLL_INTERVAL_MS, + MAX_CACHED_WORKFLOW_RUNS, + WORKFLOW_RUN_RECENT_WINDOW_MIN, +} from './config/workflow-config.js'; + +const WORKFLOWS_SUBDIR = 'workflows'; +const RUN_FILE_PREFIX = 'wf_'; +const RUN_FILE_SUFFIX = '.json'; + +/** Hard caps on the largest per-agent strings so a 28-agent run stays compact. */ +const PROMPT_PREVIEW_MAX = 200; +const RESULT_PREVIEW_MAX = 240; + +function truncate(value: string | undefined, max: number): string | undefined { + if (typeof value !== 'string') return undefined; + return value.length > max ? `${value.slice(0, max)}…` : value; +} + +/** Drop the heavy `agents[]` for list/snapshot use. */ +export function summarizeRun(info: WorkflowRunInfo): WorkflowRunSummary { + const { agents: _agents, ...summary } = info; + void _agents; + return summary; +} + +interface DiscoveredRun { + filePath: string; + projectHash: string; + sessionUuid: string; + runId: string; +} + +/** A `workflowProgress[]` entry as it appears on disk (loosely typed for defensive parsing). */ +interface RawProgressEntry { + type?: string; + index?: number; + label?: string; + phaseIndex?: number; + phaseTitle?: string; + model?: string; + state?: string; + queuedAt?: number; + lastProgressAt?: number; + promptPreview?: string; + agentId?: string; + startedAt?: number; + attempt?: number; + tokens?: number; + toolCalls?: number; + lastToolName?: string; + lastToolSummary?: string; + durationMs?: number; + resultPreview?: string; +} + +export class WorkflowRunWatcher extends EventEmitter { + private projectsDir: string; + private pollTimer: NodeJS.Timeout | null = null; + private _isRunning = false; + + /** runId -> latest parsed run info (LRU-bounded). */ + private runs = new LRUMap({ maxSize: MAX_CACHED_WORKFLOW_RUNS }); + /** absolute run-file path -> last seen mtimeMs (skip unchanged files). */ + private fileMtimes = new Map(); + /** runId -> absolute run-file path (for mtime cleanup on removal). */ + private runIdToPath = new Map(); + /** workflows-dir absolute path -> chokidar watcher (one per live run dir). */ + private dirWatchers = new Map(); + + constructor(projectsDir?: string) { + super(); + this.projectsDir = projectsDir || join(homedir(), '.claude', 'projects'); + this.setMaxListeners(50); + } + + // ========== Public API ========== + + isRunning(): boolean { + return this._isRunning; + } + + start(): void { + if (this._isRunning) return; + this._isRunning = true; + this.poll(); + this.pollTimer = setInterval(() => this.poll(), WORKFLOW_RUN_POLL_INTERVAL_MS); + } + + stop(): void { + this._isRunning = false; + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + for (const watcher of this.dirWatchers.values()) { + watcher.close().catch(() => {}); // best-effort teardown + } + this.dirWatchers.clear(); + this.runs.clear(); + this.fileMtimes.clear(); + this.runIdToPath.clear(); + } + + /** All cached runs (no recency filter), most-recently-active first. */ + getAllRuns(): WorkflowRunInfo[] { + return Array.from(this.runs.values()).sort((a, b) => b.lastActivityAt - a.lastActivityAt); + } + + /** Runs active within the last `minutes`, most-recently-active first. */ + getRecentRuns(minutes: number = WORKFLOW_RUN_RECENT_WINDOW_MIN): WorkflowRunInfo[] { + const cutoff = Date.now() - minutes * 60_000; + return this.getAllRuns().filter((r) => r.lastActivityAt >= cutoff); + } + + /** + * Lightweight summaries (no agents[]) of ALL cached runs, most-recently-active + * first. This is the LEFT-pane list + getLightState snapshot source: the cache + * is LRU-bounded (MAX_CACHED_WORKFLOW_RUNS), so it's already size-capped, and a + * run-browser should show past runs — NOT hide everything older than a window. + */ + getAllRunSummaries(): WorkflowRunSummary[] { + return this.getAllRuns().map(summarizeRun); + } + + /** Summaries filtered to the last `minutes` of activity (opt-in via ?minutes). */ + getRecentRunSummaries(minutes: number = WORKFLOW_RUN_RECENT_WINDOW_MIN): WorkflowRunSummary[] { + return this.getRecentRuns(minutes).map(summarizeRun); + } + + getRun(runId: string): WorkflowRunInfo | undefined { + return this.runs.get(runId); + } + + getStats(): { runCount: number; running: number; agentCount: number } { + let running = 0; + let agentCount = 0; + for (const run of this.runs.values()) { + if (run.status === 'running') running++; + agentCount += run.agents.length; + } + return { runCount: this.runs.size, running, agentCount }; + } + + // ========== Private ========== + + private poll(): void { + this.pollAsync().catch(() => { + // Filesystem may be transiently unavailable; the next poll retries. + }); + } + + private async pollAsync(): Promise { + const { files, dirs } = await this.discover(); + + // Install a live watcher for each run dir; tear down watchers for dirs that vanished. + for (const dir of dirs) this.ensureDirWatcher(dir); + for (const dir of Array.from(this.dirWatchers.keys())) { + if (!dirs.has(dir)) this.removeDirWatcher(dir); + } + + const seenRunIds = new Set(); + for (const file of files) { + seenRunIds.add(file.runId); + await this.maybeParse(file); + } + + // Removal by set-diff: a cached run whose file disappeared. + for (const runId of Array.from(this.runs.keys())) { + if (!seenRunIds.has(runId)) { + this.runs.delete(runId); + const path = this.runIdToPath.get(runId); + if (path) this.fileMtimes.delete(path); + this.runIdToPath.delete(runId); + this.emit('run_removed', { runId }); + } + } + } + + /** Walk projects///workflows/ for wf_*.json files. */ + private async discover(): Promise<{ files: DiscoveredRun[]; dirs: Set }> { + const files: DiscoveredRun[] = []; + const dirs = new Set(); + + let projectHashes: string[]; + try { + projectHashes = await readdir(this.projectsDir); + } catch { + return { files, dirs }; + } + + for (const projectHash of projectHashes) { + let sessions: string[]; + try { + sessions = await readdir(join(this.projectsDir, projectHash)); + } catch { + continue; + } + for (const sessionUuid of sessions) { + const workflowsDir = join(this.projectsDir, projectHash, sessionUuid, WORKFLOWS_SUBDIR); + let names: string[]; + try { + names = await readdir(workflowsDir); + } catch { + continue; // no workflows dir for this session — normal + } + let hasRun = false; + for (const name of names) { + if (!name.startsWith(RUN_FILE_PREFIX) || !name.endsWith(RUN_FILE_SUFFIX)) continue; + hasRun = true; + files.push({ + filePath: join(workflowsDir, name), + projectHash, + sessionUuid, + runId: name.slice(0, -RUN_FILE_SUFFIX.length), + }); + } + if (hasRun) dirs.add(workflowsDir); + } + } + return { files, dirs }; + } + + private async maybeParse(file: DiscoveredRun): Promise { + let mtime: number; + try { + mtime = (await stat(file.filePath)).mtimeMs; + } catch { + return; // vanished between discover and stat + } + if (this.fileMtimes.get(file.filePath) === mtime) return; + this.fileMtimes.set(file.filePath, mtime); + + const info = await this.parseFile(file); + if (!info) return; + + const existed = this.runs.has(info.runId); + this.runs.set(info.runId, info); + this.runIdToPath.set(info.runId, file.filePath); + this.emit(existed ? 'run_updated' : 'run_discovered', info); + } + + /** + * Parse a wf_.json into WorkflowRunInfo, STRIPPING the heavyweight + * `script`/`scriptPath`/`result`/`logs` fields (the embedded script alone is + * 15–660KB) so they never reach the cache, SSE, or routes. + */ + private async parseFile(file: DiscoveredRun): Promise { + let raw: Record; + try { + raw = JSON.parse(await readFile(file.filePath, 'utf-8')) as Record; + } catch { + return null; // mid-write or malformed — next mtime change re-parses + } + if (!raw || typeof raw !== 'object') return null; + + const progress = Array.isArray(raw.workflowProgress) ? (raw.workflowProgress as RawProgressEntry[]) : []; + const agents: WorkflowAgentInfo[] = progress + .filter((e) => e && e.type === 'workflow_agent') + .map((e) => this.toAgent(e)); + + const phases: WorkflowRunPhase[] = Array.isArray(raw.phases) + ? (raw.phases as Array>).map((p) => ({ + title: typeof p.title === 'string' ? p.title : '', + detail: typeof p.detail === 'string' ? p.detail : '', + })) + : []; + + let lastProgress = 0; + for (const a of agents) { + if (typeof a.lastProgressAt === 'number' && a.lastProgressAt > lastProgress) lastProgress = a.lastProgressAt; + } + const startTime = typeof raw.startTime === 'number' ? raw.startTime : undefined; + const lastActivityAt = lastProgress || startTime || 0; + + return { + runId: typeof raw.runId === 'string' ? raw.runId : file.runId, + workflowName: typeof raw.workflowName === 'string' ? raw.workflowName : undefined, + status: typeof raw.status === 'string' ? raw.status : undefined, + summary: typeof raw.summary === 'string' ? raw.summary : undefined, + agentCount: typeof raw.agentCount === 'number' ? raw.agentCount : undefined, + totalTokens: typeof raw.totalTokens === 'number' ? raw.totalTokens : undefined, + totalToolCalls: typeof raw.totalToolCalls === 'number' ? raw.totalToolCalls : undefined, + durationMs: typeof raw.durationMs === 'number' ? raw.durationMs : undefined, + startTime, + timestamp: typeof raw.timestamp === 'string' ? raw.timestamp : undefined, + defaultModel: typeof raw.defaultModel === 'string' ? raw.defaultModel : undefined, + taskId: typeof raw.taskId === 'string' ? raw.taskId : undefined, + error: typeof raw.error === 'string' ? raw.error : undefined, + phases, + agents, + sessionUuid: file.sessionUuid, + projectHash: file.projectHash, + lastActivityAt, + }; + } + + private toAgent(e: RawProgressEntry): WorkflowAgentInfo { + return { + index: typeof e.index === 'number' ? e.index : 0, + label: typeof e.label === 'string' ? e.label : '', + phaseIndex: typeof e.phaseIndex === 'number' ? e.phaseIndex : 0, + phaseTitle: typeof e.phaseTitle === 'string' ? e.phaseTitle : '', + model: typeof e.model === 'string' ? e.model : '', + state: typeof e.state === 'string' ? e.state : 'start', + queuedAt: e.queuedAt, + lastProgressAt: e.lastProgressAt, + promptPreview: truncate(e.promptPreview, PROMPT_PREVIEW_MAX), + agentId: e.agentId, + startedAt: e.startedAt, + attempt: e.attempt, + tokens: e.tokens, + toolCalls: e.toolCalls, + lastToolName: e.lastToolName, + lastToolSummary: truncate(e.lastToolSummary, RESULT_PREVIEW_MAX), + durationMs: e.durationMs, + resultPreview: truncate(e.resultPreview, RESULT_PREVIEW_MAX), + }; + } + + private ensureDirWatcher(workflowsDir: string): void { + if (this.dirWatchers.has(workflowsDir)) return; + try { + const watcher = chokidarWatch(workflowsDir, { + depth: 0, + awaitWriteFinish: { stabilityThreshold: 200 }, + ignoreInitial: true, + persistent: false, + }); + const handler = () => this.poll(); + watcher.on('add', handler); + watcher.on('change', handler); + watcher.on('unlink', handler); + watcher.on('error', () => { + // chokidar surfaced an error for this dir — drop the watcher; poll still covers it. + this.removeDirWatcher(workflowsDir); + }); + this.dirWatchers.set(workflowsDir, watcher); + } catch { + // Watch setup failed — periodic poll still discovers changes. + } + } + + private removeDirWatcher(workflowsDir: string): void { + const watcher = this.dirWatchers.get(workflowsDir); + if (watcher) { + watcher.close().catch(() => {}); + this.dirWatchers.delete(workflowsDir); + } + } +} + +/** Process-wide singleton (mirrors subagentWatcher / imageWatcher). */ +export const workflowRunWatcher = new WorkflowRunWatcher(); diff --git a/test/routes/workflow-routes.test.ts b/test/routes/workflow-routes.test.ts new file mode 100644 index 00000000..0ccdc5c6 --- /dev/null +++ b/test/routes/workflow-routes.test.ts @@ -0,0 +1,128 @@ +/** + * Route tests for the ultracode workflow endpoints: + * GET /api/workflows → run summaries (no agents[]) + * GET /api/workflows/:runId → full run (with agents[]) or 404 + * + * Uses app.inject() — no real ports. The workflow-run-watcher singleton is mocked + * so we control the returned data (positive + not-found) without touching ~/.claude. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerSystemRoutes } from '../../src/web/routes/system-routes.js'; +import { vi } from 'vitest'; + +// ── Mocks required by registerSystemRoutes ────────────────────────── +vi.mock('node:fs/promises', () => ({ + default: { readFile: vi.fn(async () => '{}'), writeFile: vi.fn(async () => undefined) }, +})); +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) }; +}); +vi.mock('../../src/subagent-watcher.js', () => ({ + subagentWatcher: { + getSubagents: vi.fn(() => []), + getRecentSubagents: vi.fn(() => []), + isRunning: vi.fn(() => true), + start: vi.fn(), + stop: vi.fn(), + }, +})); +vi.mock('../../src/image-watcher.js', () => ({ + imageWatcher: { isRunning: vi.fn(() => false), start: vi.fn(), stop: vi.fn(), watchSession: vi.fn() }, +})); +vi.mock('../../src/session-lifecycle-log.js', () => ({ + getLifecycleLog: vi.fn(() => ({ log: vi.fn(), query: vi.fn(async () => []) })), +})); +vi.mock('../../src/utils/opencode-cli-resolver.js', () => ({ + isOpenCodeAvailable: vi.fn(() => false), + resolveOpenCodeDir: vi.fn(() => null), +})); + +const SUMMARY = { + runId: 'wf_abc123', + workflowName: 'review-open-prs', + status: 'completed', + summary: 'Deep review', + agentCount: 2, + totalTokens: 1000, + totalToolCalls: 10, + phases: [{ title: 'Review', detail: 'one per PR' }], + sessionUuid: 'sess-1', + projectHash: 'proj-1', + lastActivityAt: 123, +}; +const FULL_RUN = { + ...SUMMARY, + agents: [ + { + index: 1, + label: 'review:pr-1', + phaseIndex: 1, + phaseTitle: 'Review', + model: 'opus', + state: 'done', + tokens: 500, + toolCalls: 5, + }, + { + index: 2, + label: 'review:pr-2', + phaseIndex: 1, + phaseTitle: 'Review', + model: 'opus', + state: 'done', + tokens: 500, + toolCalls: 5, + }, + ], +}; + +vi.mock('../../src/workflow-run-watcher.js', () => ({ + workflowRunWatcher: { + getAllRunSummaries: vi.fn(() => [SUMMARY]), + getRecentRunSummaries: vi.fn(() => [SUMMARY]), + getRun: vi.fn((runId: string) => (runId === 'wf_abc123' ? FULL_RUN : undefined)), + isRunning: vi.fn(() => false), + start: vi.fn(), + stop: vi.fn(), + }, +})); + +describe('workflow routes', () => { + let harness: RouteTestHarness; + + beforeEach(async () => { + harness = await createRouteTestHarness(registerSystemRoutes); + }); + afterEach(async () => { + await harness.app.close(); + }); + + it('GET /api/workflows returns the envelope with summaries (no agents[])', async () => { + const res = await harness.app.inject({ method: 'GET', url: '/api/workflows' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data).toHaveLength(1); + expect(body.data[0].runId).toBe('wf_abc123'); + expect('agents' in body.data[0]).toBe(false); + }); + + it('GET /api/workflows/:runId returns the full run with agents[]', async () => { + const res = await harness.app.inject({ method: 'GET', url: '/api/workflows/wf_abc123' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.data.agents).toHaveLength(2); + expect(body.data.agents[0].tokens).toBe(500); + }); + + it('GET /api/workflows/:runId 404s for an unknown run', async () => { + const res = await harness.app.inject({ method: 'GET', url: '/api/workflows/wf_nope' }); + const body = res.json(); + expect(body.success).toBe(false); + expect(body.errorCode).toBe('NOT_FOUND'); + }); +}); diff --git a/test/workflow-run-watcher.test.ts b/test/workflow-run-watcher.test.ts new file mode 100644 index 00000000..deab7f1c --- /dev/null +++ b/test/workflow-run-watcher.test.ts @@ -0,0 +1,212 @@ +/** + * Tests for WorkflowRunWatcher — parses wf_.json run-state into + * WorkflowRunInfo for the ultracode master-detail view. + * + * Drives the real discover→parse path against a synthetic on-disk fixture in a + * temp projects dir (never the shared singleton, never ~/.claude). + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { WorkflowRunWatcher } from '../src/workflow-run-watcher.js'; +import type { WorkflowRunInfo } from '../src/types/workflow-run.js'; + +const PROJECT_HASH = '-home-arkon-default-claudeman'; +const SESSION_UUID = '388113c8-cd01-4e80-93a8-3be66ab1519b'; +const RUN_ID = 'wf_test1234-abc'; + +/** A run JSON shaped like a real (killed) run: all three agent states + the bloat fields. */ +function sampleRunJson() { + return { + runId: RUN_ID, + timestamp: '2026-06-15T00:00:00.000Z', + taskId: 'task_abc', + // --- bloat fields that MUST be stripped --- + script: 'export const meta = {};\n'.repeat(5000), // ~110KB + scriptPath: '/tmp/whatever.js', + result: { plan: { huge: 'object' } }, + logs: ['line1', 'line2'], + // --- real fields --- + agentCount: 3, + durationMs: 795173, + summary: 'Deep adversarial review of open PRs', + workflowName: 'review-open-prs', + status: 'killed', + error: 'user stopped the task', + startTime: 1781466999000, + defaultModel: 'claude-opus-4-8[1m]', + totalTokens: 109703, + totalToolCalls: 44, + phases: [ + { title: 'Review', detail: 'one deep reviewer per PR' }, + { title: 'Probe', detail: 'targeted security/correctness probes' }, + { title: 'Verify', detail: 'adversarially verify each finding' }, + ], + workflowProgress: [ + { type: 'workflow_phase', index: 0, phaseIndex: 1, phaseTitle: 'Review' }, + { + type: 'workflow_agent', + index: 1, + label: 'probe:dompurify-config', + phaseIndex: 2, + phaseTitle: 'Probe', + agentId: 'a6c0e282c3f5ac0bf', + model: 'claude-opus-4-8[1m]', + state: 'done', + startedAt: 1781467000002, + queuedAt: 1781466999962, + attempt: 1, + lastToolName: 'StructuredOutput', + lastToolSummary: 'Does the profile setting make the allowlist dead config', + promptPreview: 'You are reviewing a pull request...', + lastProgressAt: 1781467524143, + tokens: 104703, + toolCalls: 41, + durationMs: 524140, + resultPreview: '{"verdict":"concern"}', + }, + { + type: 'workflow_agent', + index: 2, + label: 'review:pr-127', + phaseIndex: 1, + phaseTitle: 'Review', + agentId: 'a1234567890abcdef', + model: 'claude-opus-4-8[1m]', + state: 'progress', + startedAt: 1781467010000, + queuedAt: 1781466999970, + attempt: 1, + lastToolName: 'Read', + promptPreview: 'Review PR 127...', + lastProgressAt: 1781467600000, + tokens: 5000, + toolCalls: 3, + }, + { + type: 'workflow_agent', + index: 3, + label: 'verify:finding-x', + phaseIndex: 3, + phaseTitle: 'Verify', + model: 'claude-opus-4-8[1m]', + state: 'start', + queuedAt: 1781466999980, + promptPreview: 'Verify finding x...', + lastProgressAt: 1781466999980, + }, + ], + }; +} + +describe('WorkflowRunWatcher', () => { + let projectsDir: string; + let watcher: WorkflowRunWatcher; + + beforeEach(async () => { + projectsDir = await mkdtemp(join(tmpdir(), 'wfw-test-')); + const workflowsDir = join(projectsDir, PROJECT_HASH, SESSION_UUID, 'workflows'); + await mkdir(workflowsDir, { recursive: true }); + await writeFile(join(workflowsDir, `${RUN_ID}.json`), JSON.stringify(sampleRunJson()), 'utf-8'); + watcher = new WorkflowRunWatcher(projectsDir); + }); + + afterEach(async () => { + watcher.stop(); + await rm(projectsDir, { recursive: true, force: true }); + }); + + /** Start the watcher and resolve with the first discovered run. */ + function firstRun(): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('timed out waiting for run_discovered')), 5000); + watcher.once('run_discovered', (info: WorkflowRunInfo) => { + clearTimeout(timer); + resolve(info); + }); + watcher.start(); + }); + } + + it('discovers and parses a run, deriving session/project from the path', async () => { + const info = await firstRun(); + expect(info.runId).toBe(RUN_ID); + expect(info.workflowName).toBe('review-open-prs'); + expect(info.status).toBe('killed'); + expect(info.error).toBe('user stopped the task'); + expect(info.sessionUuid).toBe(SESSION_UUID); + expect(info.projectHash).toBe(PROJECT_HASH); + expect(info.totalTokens).toBe(109703); + expect(info.totalToolCalls).toBe(44); + }); + + it('keeps only workflow_agent entries (drops workflow_phase markers)', async () => { + const info = await firstRun(); + expect(info.agents).toHaveLength(3); + expect(info.phases).toHaveLength(3); + }); + + it('STRIPS the heavyweight script/scriptPath/result/logs fields', async () => { + const info = await firstRun(); + const asAny = info as unknown as Record; + expect('script' in asAny).toBe(false); + expect('scriptPath' in asAny).toBe(false); + expect('result' in asAny).toBe(false); + expect('logs' in asAny).toBe(false); + // The serialized run that reaches a client must be small. + expect(JSON.stringify(info).length).toBeLessThan(5000); + }); + + it('carries tokens/toolCalls/durationMs on a done agent', async () => { + const info = await firstRun(); + const done = info.agents.find((a) => a.state === 'done')!; + expect(done.agentId).toBe('a6c0e282c3f5ac0bf'); + expect(done.tokens).toBe(104703); + expect(done.toolCalls).toBe(41); + expect(done.durationMs).toBe(524140); + expect(done.resultPreview).toBeDefined(); + }); + + it('omits agentId/tokens/toolCalls/durationMs on a start (queued) agent', async () => { + const info = await firstRun(); + const queued = info.agents.find((a) => a.state === 'start')!; + expect(queued.agentId).toBeUndefined(); + expect(queued.tokens).toBeUndefined(); + expect(queued.toolCalls).toBeUndefined(); + expect(queued.durationMs).toBeUndefined(); + expect(queued.label).toBe('verify:finding-x'); + }); + + it('a progress agent has tokens but no durationMs (live discriminator)', async () => { + const info = await firstRun(); + const running = info.agents.find((a) => a.state === 'progress')!; + expect(running.tokens).toBe(5000); + expect(running.toolCalls).toBe(3); + expect(running.durationMs).toBeUndefined(); + }); + + it('phase join: agent.phaseIndex-1 indexes run.phases', async () => { + const info = await firstRun(); + for (const agent of info.agents) { + expect(info.phases[agent.phaseIndex - 1].title).toBe(agent.phaseTitle); + } + }); + + it('exposes the run via getAllRuns/getRun after discovery', async () => { + await firstRun(); + expect(watcher.getAllRuns()).toHaveLength(1); + expect(watcher.getRun(RUN_ID)?.runId).toBe(RUN_ID); + expect(watcher.getStats().agentCount).toBe(3); + }); + + it('getRecentRunSummaries omits agents[] (lightweight snapshot)', async () => { + await firstRun(); + const summaries = watcher.getRecentRunSummaries(100000); + expect(summaries).toHaveLength(1); + expect('agents' in summaries[0]).toBe(false); + expect(summaries[0].runId).toBe(RUN_ID); + expect(summaries[0].agentCount).toBe(3); + }); +});