diff --git a/CHANGELOG.md b/CHANGELOG.md index f2bc941..55dc1e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,140 @@ Changes are tracked via git tags. Each release tag corresponds to an entry here. ## [Unreleased] -_No changes yet._ +### Changed — History tab: separate section for interactive agents (PA + HA) + +Dogfood feedback from the gmbot run: PA sessions can stay alive +for hours or days. In the History tab's single chronological list, +an active long-running PA's row gets pushed deep into the stack as +iteration events accumulate above it. Scanning for "is PA still +running?" forced scrolling. + +**Fix**: partition the events into two sections in the History tab: + +- **Interactive agents (top section)** — every PA + HA event for + this workspace (active and terminated), newest-first within the + section. Active sessions naturally appear at the top because + they're the newest by `startedAt`. The section header shows the + count: `Interactive agents (2)`. +- **Loop history (bottom section)** — iteration / review / + document / reflection / loop-stopped events, newest-first. + Unchanged from today, just gets a header (`Loop history (47)`) + when the interactive section is present. + +When there are no interactive events (most workspaces, most of the +time), there's no top section and no header on the bottom — the +table looks exactly like it does today. The new structure only +appears when it's needed. + +**Critical UX rule**: every event has exactly ONE permanent home. +A PA's row stays in the Interactive section regardless of status — +no disappearing-on-completion, no relocating to its chronological +slot in section B when it terminates. Status badge differentiates +running/completed/failed within the section. This was the +load-bearing correction in the design discussion: the initial +"active-only" framing would have moved terminated PAs out of +the top section, hiding history. The corrected framing partitions +by event-type only. + +**Multiple active PAs** (user-error case): all appear in section A +with their `running` status badges. No warning today — defer until +real users hit it. + +**Where F.22's active-agent chip fits**: this is the History tab +counterpart of F.22 (which solved the same "what's running RIGHT +NOW" problem on the workspace card / Status tab, but only for +loop-spawned roles — F.22 doesn't track PA / HA because they're +not in `active-processes` registry). Together: F.22 = loop on the +dashboard / Status tab; this PR = interactive on the History tab. + +**Implementation** (~125 LoC + tests): + +- New `packages/web/src/utils/history-partition.ts` — pure helper + `partitionInteractiveEvents(events)` returns + `{interactive, loop}`, both newest-first. Extensible: today + `INTERACTIVE_EVENT_TYPES = new Set(["pa-session"])`; when HA + grows a history-event type, add it to the set — no other code + changes needed. +- `packages/web/src/components/WorkspaceHistory.tsx` — render two + `
` blocks (with headers + count) when interactive + events exist, or fall back to the original single-table render + when they don't. + +**Test coverage** (10 new tests in `history-partition.test.ts`, +all 1071 total pass): partition splits correctly; both arrays +sorted newest-first; active PA stays at top of interactive section +regardless of startedAt; terminated events stay in their section +(status does NOT change partition); handles all loop event types; +empty input; partition is total (no events lost); does not mutate +input array. + +### Added — Workspace card + Status tab: "PA active" chip + +Natural extension of the History tab partition (same PR): the +F.22 active-agent chip on the workspace card shows loop / review +/ document / reflect activity, but does NOT surface PA. PA runs +outside the cfcf server (interactive `cfcf spec`), so it's +tracked separately. This adds a parallel chip — *"● PA active"* — +that appears alongside the F.22 chip when a PA session is alive +for the workspace. + +**Why a separate chip, not a new `activeAgent` enum value**: F.22's +`activeAgent` is mutually exclusive (the loop runs one phase at a +time). PA can run **concurrently** with loop / standalone runs. +Two chips can coexist on the card. The PA chip uses a different +accent color so they're visually separable at a glance. + +**Liveness check**: per-call `process.kill(launcherPid, 0)` +against the recorded PID in the most-recent `pa-session` history +event with `status === "running"`. This is the same primitive +F.28's boot-reconcile uses, but called on every `/api/workspaces` +poll instead of only at server boot. **Side benefit**: if a PA +session terminates uncleanly (shell killed, terminal closed), +`status: "running"` lingers in `history.json` until the next boot. +The new check correctly reports "not active" for that case in real +time — no boot required. + +**Status tab chip**: matching `PA active` tag in the workspace +detail header (next to the existing `review running` / +`document running` / `reflect running` tags). Derived from +`history.some(e => e.type === "pa-session" && e.status === +"running")` — same trust level as the History tab itself. +PID-verified liveness is a card-surface concern (dashboard +overview); the detail-page chip trusts the on-disk status because +the user is already deep in this workspace. + +**Pre-v0.24 PA events**: events without `launcherPid` are skipped +by the liveness check rather than shown. No precise way to verify +them, and a stale chip is worse than no chip — boot-reconcile's +mtime fallback handles them at next server boot. + +**Implementation** (~120 LoC + tests): + +- New `packages/core/src/product-architect/pa-liveness.ts`: + - `getPaSessionLiveness(workspaceId)` — finds most-recent + running pa-session event with a live PID, returns details. + - `getPaSessionsForWorkspaces(ids)` — batch resolver matching + `getActiveAgentsForWorkspaces` shape. + - `isPidAlive(pid)` — extracted primitive (boot-reconcile has + its own inline version; future cleanup could migrate). +- `packages/server/src/app.ts` — `/api/workspaces` enriched with + `paSession: PaSessionLiveness | null` in parallel with the + existing `activeAgent` enrichment (both via `Promise.all`). +- `packages/web/src/types.ts` — mirror the new field. +- `packages/web/src/components/WorkspaceCard.tsx` — render + `● PA active` chip alongside F.22's chip when + `workspace.paSession?.active`; tooltip shows session id + start + time + PID. +- `packages/web/src/pages/WorkspaceDetail.tsx` — `PA active` tag + in the header alongside the other in-page chips. + +**Test coverage** (12 new tests in `pa-liveness.test.ts`, all +1071 total pass): null on missing history; null on no pa-session; +null on no `running` event; null on missing launcherPid (pre-v0.24 +event); positive case with live PID; null on dead PID; +newest-running wins when multiple alive; falls through stale newer +to surface older live; batch shape; empty input; isPidAlive +primitive correctness. ## [0.24.4] -- 2026-05-14 diff --git a/packages/core/src/product-architect/index.ts b/packages/core/src/product-architect/index.ts index 8b18c5e..5d14cb1 100644 --- a/packages/core/src/product-architect/index.ts +++ b/packages/core/src/product-architect/index.ts @@ -66,3 +66,11 @@ export { reconcileStalePaSessions, type PaReconcileResult, } from "./boot-reconcile.js"; + +// On-demand liveness check (v0.24.5 — workspace card "PA active" chip) +export { + getPaSessionLiveness, + getPaSessionsForWorkspaces, + isPidAlive, + type PaSessionLiveness, +} from "./pa-liveness.js"; diff --git a/packages/core/src/product-architect/pa-liveness.test.ts b/packages/core/src/product-architect/pa-liveness.test.ts new file mode 100644 index 0000000..1902ac5 --- /dev/null +++ b/packages/core/src/product-architect/pa-liveness.test.ts @@ -0,0 +1,230 @@ +/** + * Tests for the on-demand PA-session liveness check (v0.24.5). + * + * The "PID is alive" path is hard to exercise deterministically + * without spawning + killing real subprocesses. These tests pin + * the observable contract: history wiring, status filter, ordering + * (newest live wins), and the safe defaults around missing / + * malformed inputs. The PID primitive (`isPidAlive`) is also + * tested directly with `process.pid` (this process is definitely + * alive) + a known-dead PID. + */ + +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "fs"; +import { mkdir, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { + getPaSessionLiveness, + getPaSessionsForWorkspaces, + isPidAlive, +} from "./pa-liveness.js"; +import type { PaSessionHistoryEvent } from "../workspace-history.js"; + +let tempDir: string; +const originalConfigDir = process.env.CFCF_CONFIG_DIR; + +beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), "cfcf-pa-liveness-test-")); + process.env.CFCF_CONFIG_DIR = tempDir; +}); + +afterEach(async () => { + process.env.CFCF_CONFIG_DIR = originalConfigDir; + try { rmSync(tempDir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +/** + * Write a fake history.json for the given workspace. The format + * matches what readHistory() expects: a JSON array of events. + */ +async function writeHistory(workspaceId: string, events: Array & { type: string }>): Promise { + const dir = join(tempDir, "workspaces", workspaceId); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "history.json"), JSON.stringify(events, null, 2), "utf-8"); +} + +describe("isPidAlive", () => { + it("returns true for the current process PID (definitely alive)", () => { + expect(isPidAlive(process.pid)).toBe(true); + }); + + it("returns false for a PID that's almost certainly dead", () => { + // PID 1 is init / launchd — alive but likely owned by root, + // which would return EPERM (alive). Use a high PID unlikely + // to exist instead. + expect(isPidAlive(2147483646)).toBe(false); + }); +}); + +describe("getPaSessionLiveness", () => { + it("returns null when no history exists", async () => { + const result = await getPaSessionLiveness("nonexistent-workspace-id"); + expect(result).toBeNull(); + }); + + it("returns null when history has no pa-session events", async () => { + await writeHistory("ws1", [ + { type: "iteration", id: "iter1", status: "completed", startedAt: "2026-05-14T10:00:00.000Z" } as never, + ]); + const result = await getPaSessionLiveness("ws1"); + expect(result).toBeNull(); + }); + + it("returns null when only completed/failed pa-sessions exist (no `running` candidates)", async () => { + await writeHistory("ws1", [ + { + type: "pa-session", + id: "pa1", + status: "completed", + startedAt: "2026-05-14T10:00:00.000Z", + sessionId: "pa-2026-05-14T10-00-00-abc", + launcherPid: process.pid, + }, + ]); + const result = await getPaSessionLiveness("ws1"); + expect(result).toBeNull(); + }); + + it("returns null when running event exists but lacks launcherPid (pre-v0.24 event)", async () => { + // No precise way to verify pre-v0.24 events; we skip them + // rather than show a potentially-stale chip. boot-reconcile's + // mtime fallback handles them at next server boot. + await writeHistory("ws1", [ + { + type: "pa-session", + id: "pa1", + status: "running", + startedAt: "2026-05-14T10:00:00.000Z", + sessionId: "pa-2026-05-14T10-00-00-abc", + // launcherPid missing + }, + ]); + const result = await getPaSessionLiveness("ws1"); + expect(result).toBeNull(); + }); + + it("returns liveness details when running event has a live launcherPid", async () => { + // Using process.pid as the launcher PID — guaranteed alive + // (this test process IS that PID). + await writeHistory("ws1", [ + { + type: "pa-session", + id: "pa1", + status: "running", + startedAt: "2026-05-14T10:00:00.000Z", + sessionId: "pa-2026-05-14T10-00-00-abc123", + launcherPid: process.pid, + }, + ]); + const result = await getPaSessionLiveness("ws1"); + expect(result).not.toBeNull(); + expect(result?.active).toBe(true); + expect(result?.sessionId).toBe("pa-2026-05-14T10-00-00-abc123"); + expect(result?.launcherPid).toBe(process.pid); + expect(result?.startedAt).toBe("2026-05-14T10:00:00.000Z"); + expect(result?.eventId).toBe("pa1"); + }); + + it("returns null when running event's launcherPid is dead", async () => { + await writeHistory("ws1", [ + { + type: "pa-session", + id: "pa1", + status: "running", + startedAt: "2026-05-14T10:00:00.000Z", + sessionId: "pa-2026-05-14T10-00-00-abc", + launcherPid: 2147483646, // unlikely-to-exist PID + }, + ]); + const result = await getPaSessionLiveness("ws1"); + expect(result).toBeNull(); + }); + + it("walks newest-first; surfaces the most recent live PA when multiple are running", async () => { + await writeHistory("ws1", [ + { + type: "pa-session", + id: "pa-old", + status: "running", + startedAt: "2026-05-14T09:00:00.000Z", + sessionId: "pa-old-session", + launcherPid: process.pid, + }, + { + type: "pa-session", + id: "pa-new", + status: "running", + startedAt: "2026-05-14T11:00:00.000Z", + sessionId: "pa-new-session", + launcherPid: process.pid, + }, + ]); + const result = await getPaSessionLiveness("ws1"); + expect(result?.sessionId).toBe("pa-new-session"); + expect(result?.eventId).toBe("pa-new"); + }); + + it("falls through stale entries when the newest running event has a dead PID", async () => { + // newest = dead PID; older = live PID. The walk should + // continue past the stale one and surface the older live PA. + await writeHistory("ws1", [ + { + type: "pa-session", + id: "pa-stale-newer", + status: "running", + startedAt: "2026-05-14T11:00:00.000Z", + sessionId: "pa-stale", + launcherPid: 2147483646, // dead + }, + { + type: "pa-session", + id: "pa-live-older", + status: "running", + startedAt: "2026-05-14T10:00:00.000Z", + sessionId: "pa-live", + launcherPid: process.pid, // alive + }, + ]); + const result = await getPaSessionLiveness("ws1"); + expect(result?.sessionId).toBe("pa-live"); + }); +}); + +describe("getPaSessionsForWorkspaces (batch)", () => { + it("returns a map of workspace-id → liveness | null, with one entry per id", async () => { + await writeHistory("ws-alive", [ + { + type: "pa-session", + id: "pa1", + status: "running", + startedAt: "2026-05-14T10:00:00.000Z", + sessionId: "alive-session", + launcherPid: process.pid, + }, + ]); + await writeHistory("ws-dead", [ + { + type: "pa-session", + id: "pa2", + status: "running", + startedAt: "2026-05-14T10:00:00.000Z", + sessionId: "dead-session", + launcherPid: 2147483646, + }, + ]); + // ws-empty has no history at all + + const result = await getPaSessionsForWorkspaces(["ws-alive", "ws-dead", "ws-empty"]); + expect(Object.keys(result).sort()).toEqual(["ws-alive", "ws-dead", "ws-empty"]); + expect(result["ws-alive"]?.sessionId).toBe("alive-session"); + expect(result["ws-dead"]).toBeNull(); + expect(result["ws-empty"]).toBeNull(); + }); + + it("returns an empty object for empty input (defensive — never throws)", async () => { + const result = await getPaSessionsForWorkspaces([]); + expect(result).toEqual({}); + }); +}); diff --git a/packages/core/src/product-architect/pa-liveness.ts b/packages/core/src/product-architect/pa-liveness.ts new file mode 100644 index 0000000..4103843 --- /dev/null +++ b/packages/core/src/product-architect/pa-liveness.ts @@ -0,0 +1,124 @@ +/** + * On-demand PA-session liveness check for the workspace card chip + * + Status tab indicator. + * + * The boot-reconcile path (boot-reconcile.ts) runs ONCE at server + * startup to flip stale `running` pa-session events to `failed` via + * the same `process.kill(pid, 0)` primitive. This module exposes + * the per-workspace check as an API-callable function so the + * dashboard's "PA active" chip can reflect real liveness on every + * `/api/workspaces` poll — not just whatever the on-disk history + * status says. + * + * Side benefit beyond the chip itself: if a PA session terminates + * uncleanly (shell killed, terminal closed without an exit + * handshake), `status: "running"` lingers in history.json until + * the next server boot. This check correctly reports "not active" + * for that case in real time, no boot required. + * + * Mirrors the `getActiveAgent` / `getActiveAgentsForWorkspaces` + * pattern in `active-agent.ts` so the `/api/workspaces` enrichment + * follows a single shape. + */ + +import type { PaSessionHistoryEvent } from "../workspace-history.js"; +import { readHistory } from "../workspace-history.js"; + +export interface PaSessionLiveness { + active: true; + sessionId: string; + /** ISO timestamp when the PA session started. */ + startedAt: string; + /** Server-recorded launcher PID for the live process. */ + launcherPid: number; + /** History event id (for the UI to deep-link / filter). */ + eventId: string; +} + +/** + * Return liveness details for the workspace's PA session, or `null` + * if no live PA exists. + * + * Algorithm: + * 1. Read history events. + * 2. Filter to `pa-session` events with `status === "running"`. + * 3. Walk newest-first; the first one whose `launcherPid` is + * alive (per `process.kill(pid, 0)`) wins. + * 4. Pre-v0.24 events without `launcherPid` are skipped — there's + * no precise way to verify them, and a stale chip is worse + * than no chip. Those events are still cleaned up by + * boot-reconcile's mtime fallback at next server boot. + */ +export async function getPaSessionLiveness( + workspaceId: string, +): Promise { + let events; + try { + events = await readHistory(workspaceId); + } catch { + return null; // history file missing / unreadable; safest to claim no PA + } + + // Find pa-session events in `running` state. Sort newest-first so + // we surface the most recent live session if multiple exist + // (uncommon — usually one at most; defensive against accidental + // duplicates). + const candidates = events + .filter((e) => e.type === "pa-session" && e.status === "running") + .sort( + (a, b) => + new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), + ) as PaSessionHistoryEvent[]; + + for (const ev of candidates) { + if (typeof ev.launcherPid !== "number") continue; + if (!isPidAlive(ev.launcherPid)) continue; + return { + active: true, + sessionId: ev.sessionId, + startedAt: ev.startedAt, + launcherPid: ev.launcherPid, + eventId: ev.id, + }; + } + return null; +} + +/** + * Batch resolver for multiple workspaces — used by the dashboard + * list endpoint. Same shape as + * `getActiveAgentsForWorkspaces(...)` for callsite symmetry. + */ +export async function getPaSessionsForWorkspaces( + workspaceIds: string[], +): Promise> { + const out: Record = {}; + for (const id of workspaceIds) { + out[id] = await getPaSessionLiveness(id); + } + return out; +} + +/** + * Primitive: is this PID currently running and signalable? + * + * `process.kill(pid, 0)` sends nothing — it just exercises the + * permission/lookup path. Throws on failure: + * - ESRCH ("no such process") → not running → return false + * - EPERM ("operation not permitted") → exists but owned by + * another user → return true (still alive, just not ours) + * + * Exported for direct use by tests + the boot-reconcile path + * (which has its own inline implementation today; could migrate + * to this helper for consistency, out of scope for this commit). + */ +export function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e.code === "EPERM") return true; + return false; + } +} diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 40374b9..a73ac82 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -53,6 +53,7 @@ import { getAllActiveProcesses, getActiveProcess, killProcessTree, + getPaSessionsForWorkspaces, markReflectStateFailed, markDocumentStateFailed, markReviewStateFailed, @@ -372,11 +373,20 @@ export function createApp() { // Review/Document/Reflect runs (which don't touch workspace.status // by design — that field tracks the loop only). null when nothing // is running. + // + // v0.24.5: also enrich with `paSession` for an INDEPENDENT + // "PA active" chip. PA can run concurrently with the loop / + // standalone runs, so it's a parallel field — not a new + // `activeAgent` enum value. Two chips can coexist on the card. const ids = workspaces.map((w) => w.id); - const activeAgents = await getActiveAgentsForWorkspaces(ids); + const [activeAgents, paSessions] = await Promise.all([ + getActiveAgentsForWorkspaces(ids), + getPaSessionsForWorkspaces(ids), + ]); const enriched = workspaces.map((w) => ({ ...w, activeAgent: activeAgents[w.id] ?? null, + paSession: paSessions[w.id] ?? null, })); return c.json(enriched); }); diff --git a/packages/web/src/components/WorkspaceCard.tsx b/packages/web/src/components/WorkspaceCard.tsx index edd562a..7ff42c9 100644 --- a/packages/web/src/components/WorkspaceCard.tsx +++ b/packages/web/src/components/WorkspaceCard.tsx @@ -25,6 +25,14 @@ function activeAgentLabel(activeAgent: WorkspaceConfig["activeAgent"]): string | export function WorkspaceCard({ workspace }: { workspace: WorkspaceConfig }) { const activeLabel = activeAgentLabel(workspace.activeAgent); + // v0.24.5: independent chip for PA-session liveness. PA runs + // outside the cfcf server (interactive `cfcf spec`), so it's + // tracked separately from F.22's activeAgent and can coexist + // with it on the card. + const pa = workspace.paSession; + const paTooltip = pa + ? `PA session ${pa.sessionId} alive since ${new Date(pa.startedAt).toLocaleString()} (launcher PID ${pa.launcherPid})` + : ""; return (
)} + {pa && ( + + ● PA active + + )}
diff --git a/packages/web/src/components/WorkspaceHistory.tsx b/packages/web/src/components/WorkspaceHistory.tsx index 192d489..12d51d6 100644 --- a/packages/web/src/components/WorkspaceHistory.tsx +++ b/packages/web/src/components/WorkspaceHistory.tsx @@ -20,6 +20,7 @@ import { deriveJudgeRowStatus, deriveJudgeRowTime, } from "../utils/iteration-row-status"; +import { partitionInteractiveEvents } from "../utils/history-partition"; const determinationColor: Record = { SUCCESS: "var(--color-success)", @@ -82,49 +83,161 @@ export function WorkspaceHistory({ ); } - // Sort newest first - const sorted = [...events].sort( - (a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), - ); + // Split into interactive (PA/HA) + loop (everything else). Both + // arrays come back newest-first. Rationale: PA sessions can run + // for hours/days, and in a single chronological list an active + // long-running PA gets pushed deep by accumulating iteration + // events. Giving interactive agents their own stable section + // keeps "PA is alive" findable. Every event still has exactly + // one home — terminated PAs stay in the Interactive section + // (status badge tells running-vs-completed). See + // `utils/history-partition.ts` for the full rationale. + const { interactive, loop } = partitionInteractiveEvents(events); + + // Shared summary style: visual hierarchy + disclosure-triangle + // affordance via the native
/ element (no + // React state needed — browser handles open/close + persisted + // state across rerenders). Each section gets a left-border + // accent so the two sections feel optically distinct. + const summaryBase: React.CSSProperties = { + cursor: "pointer", + padding: "0.4rem 0.6rem 0.4rem 0.8rem", + margin: "0 0 0.4rem 0", + fontSize: "var(--text-md)", + fontWeight: 600, + color: "var(--color-text)", + background: "color-mix(in srgb, var(--color-text-muted) 6%, transparent)", + borderRadius: "4px", + userSelect: "none", + }; return (
- - - - - - - - - - - - - - {sorted.map((e) => - e.type === "iteration" ? ( - - ) : ( - - ), - )} - -
TimeTypeAgentStatusResultDurationLog
+ {interactive.length > 0 && ( +
+ + Interactive sessions ({interactive.length}) + + + + + + + + + + + + + + + {interactive.map((e) => ( + + ))} + +
TimeTypeAgentStatusResultDurationLog
+
+ )} + {/* The loop section uses
+ only when the + interactive section is also rendered (so the two are + visually paired as collapsible sections). When there's + no interactive section, the loop renders as a plain + table — matches today's layout exactly, no disclosure + affordance for a single-section view. */} + {interactive.length > 0 ? ( +
+ + Loop history ({loop.length}) + + +
+ ) : ( + + )}
); } +/** + * The loop-events table extracted as a small subcomponent so it + * can be rendered either inside a
(when the Interactive + * section is present and both should be collapsible) or as a + * plain bare table (when there are no interactive events — keeps + * today's flat layout for the common case). + */ +function LoopHistoryTable({ + loop, + workspaceId, + onSelectLog, +}: { + loop: HistoryEvent[]; + workspaceId: string; + onSelectLog: (target: LogTarget) => void; +}) { + return ( + + + + + + + + + + + + + + {loop.map((e) => + e.type === "iteration" ? ( + + ) : ( + + ), + )} + +
TimeTypeAgentStatusResultDurationLog
+ ); +} + function HistoryRow({ event, workspaceId, diff --git a/packages/web/src/pages/WorkspaceDetail.tsx b/packages/web/src/pages/WorkspaceDetail.tsx index 118f3fb..dcc996a 100644 --- a/packages/web/src/pages/WorkspaceDetail.tsx +++ b/packages/web/src/pages/WorkspaceDetail.tsx @@ -248,6 +248,21 @@ export function WorkspaceDetail({ workspaceId }: { workspaceId: string }) { {isReviewActive && review running} {isDocumentActive && document running} {isReflectActive && reflect running} + {/* v0.24.5: in-page chip for PA session liveness. Derived + from `history` (status === "running" on a pa-session + event) — matches the same pattern as the chips above + that read from in-memory state. PID-verified liveness + via the server-enriched `workspace.paSession` is on the + workspace card (dashboard surface); the in-page chip + trusts the same on-disk status the History tab does. */} + {history.some((e) => e.type === "pa-session" && e.status === "running") && ( + + PA active + + )}
): PaSessionHistoryEvent { + return { + id: `pa-${Math.random().toString(36).slice(2, 10)}`, + type: "pa-session", + status: "completed", + startedAt: "2026-05-14T10:00:00.000Z", + logFile: "pa.log", + agent: "claude-code", + sessionId: "pa-2026-05-14T10-00-00-abc123", + sessionFilePath: ".cfcf-pa/session-pa-2026-05-14T10-00-00-abc123.md", + workspaceRegisteredAtStart: true, + gitInitializedAtStart: true, + problemPackFilesAtStart: 3, + ...overrides, + }; +} + +function makeIteration(overrides?: Partial): IterationHistoryEvent { + return { + id: `iter-${Math.random().toString(36).slice(2, 10)}`, + type: "iteration", + status: "completed", + startedAt: "2026-05-14T11:00:00.000Z", + logFile: "iter.log", + agent: "codex", + iteration: 1, + branch: "cfcf/iteration-1", + devLogFile: "iter-1-dev.log", + judgeLogFile: "iter-1-judge.log", + devAgent: "codex", + judgeAgent: "codex", + ...overrides, + }; +} + +function makeReview(overrides?: Partial): ReviewHistoryEvent { + return { + id: `review-${Math.random().toString(36).slice(2, 10)}`, + type: "review", + status: "completed", + startedAt: "2026-05-14T12:00:00.000Z", + logFile: "review.log", + agent: "claude-code", + trigger: "manual", + ...overrides, + }; +} + +function makeReflection(overrides?: Partial): ReflectionHistoryEvent { + return { + id: `refl-${Math.random().toString(36).slice(2, 10)}`, + type: "reflection", + status: "completed", + startedAt: "2026-05-14T13:00:00.000Z", + logFile: "refl.log", + agent: "claude-code", + iteration: 1, + trigger: "loop", + ...overrides, + }; +} + +function makeDocument(overrides?: Partial): DocumentHistoryEvent { + return { + id: `doc-${Math.random().toString(36).slice(2, 10)}`, + type: "document", + status: "completed", + startedAt: "2026-05-14T14:00:00.000Z", + logFile: "doc.log", + agent: "claude-code", + ...overrides, + }; +} + +function makeLoopStopped(overrides?: Partial): LoopStoppedHistoryEvent { + return { + id: `stopped-${Math.random().toString(36).slice(2, 10)}`, + type: "loop-stopped", + status: "completed", + startedAt: "2026-05-14T15:00:00.000Z", + logFile: "", + iteration: 5, + ...overrides, + }; +} + +describe("INTERACTIVE_EVENT_TYPES", () => { + test("contains pa-session (the only interactive event type today)", () => { + expect(INTERACTIVE_EVENT_TYPES.has("pa-session")).toBe(true); + }); + + test("does NOT contain loop event types — those are the audit trail of automation", () => { + expect(INTERACTIVE_EVENT_TYPES.has("iteration")).toBe(false); + expect(INTERACTIVE_EVENT_TYPES.has("review")).toBe(false); + expect(INTERACTIVE_EVENT_TYPES.has("reflection")).toBe(false); + expect(INTERACTIVE_EVENT_TYPES.has("document")).toBe(false); + expect(INTERACTIVE_EVENT_TYPES.has("loop-stopped")).toBe(false); + }); +}); + +describe("partitionInteractiveEvents", () => { + test("splits PA sessions into interactive[], everything else into loop[]", () => { + const events: HistoryEvent[] = [ + makePaSession({ id: "pa1" }), + makeIteration({ id: "iter1" }), + makeReview({ id: "rev1" }), + makePaSession({ id: "pa2" }), + makeDocument({ id: "doc1" }), + ]; + const result = partitionInteractiveEvents(events); + expect(result.interactive.map((e) => e.id).sort()).toEqual(["pa1", "pa2"]); + expect(result.loop.map((e) => e.id).sort()).toEqual(["doc1", "iter1", "rev1"]); + }); + + test("both arrays come back sorted newest-first (descending by startedAt)", () => { + const events: HistoryEvent[] = [ + makePaSession({ id: "pa-old", startedAt: "2026-05-14T08:00:00.000Z" }), + makePaSession({ id: "pa-new", startedAt: "2026-05-14T10:00:00.000Z" }), + makePaSession({ id: "pa-mid", startedAt: "2026-05-14T09:00:00.000Z" }), + makeIteration({ id: "iter-old", startedAt: "2026-05-14T11:00:00.000Z" }), + makeIteration({ id: "iter-new", startedAt: "2026-05-14T13:00:00.000Z" }), + makeIteration({ id: "iter-mid", startedAt: "2026-05-14T12:00:00.000Z" }), + ]; + const result = partitionInteractiveEvents(events); + expect(result.interactive.map((e) => e.id)).toEqual(["pa-new", "pa-mid", "pa-old"]); + expect(result.loop.map((e) => e.id)).toEqual(["iter-new", "iter-mid", "iter-old"]); + }); + + test("ACTIVE PA stays at the top of interactive[] (newest startedAt) regardless of status", () => { + // The load-bearing case for this whole feature: a long-running + // PA that started days ago is still alive. New iteration events + // accumulate. The active PA stays findable in section A. + const activeOldPa = makePaSession({ + id: "pa-running", + status: "running", + startedAt: "2026-05-12T08:00:00.000Z", // started 2 days ago + }); + const recentIter = makeIteration({ + id: "iter-recent", + startedAt: "2026-05-14T15:00:00.000Z", // just now + }); + const recentlyCompletedPa = makePaSession({ + id: "pa-just-done", + status: "completed", + startedAt: "2026-05-14T14:00:00.000Z", // an hour ago, completed + }); + const events: HistoryEvent[] = [recentIter, activeOldPa, recentlyCompletedPa]; + const result = partitionInteractiveEvents(events); + // The "just-done" PA is newer-by-startedAt and shows first; the + // active 2-day-old PA is second. Both are in interactive[], + // independent of status — the row is FINDABLE either way because + // section A has at most a handful of entries. + expect(result.interactive.map((e) => e.id)).toEqual(["pa-just-done", "pa-running"]); + expect(result.loop.map((e) => e.id)).toEqual(["iter-recent"]); + }); + + test("TERMINATED interactive events stay in interactive[] (status does NOT change partition)", () => { + // The correction the user pushed for in design review: don't + // move events between sections when status flips. If a PA was + // ever interactive, it stays in interactive[] forever — no + // disappearing-on-completion, no "where did it go" confusion. + const events: HistoryEvent[] = [ + makePaSession({ id: "pa-running", status: "running" }), + makePaSession({ id: "pa-completed", status: "completed" }), + makePaSession({ id: "pa-failed", status: "failed" }), + ]; + const result = partitionInteractiveEvents(events); + expect(result.interactive).toHaveLength(3); + expect(result.loop).toHaveLength(0); + }); + + test("handles all loop event types correctly", () => { + const events: HistoryEvent[] = [ + makeIteration(), + makeReview(), + makeReflection(), + makeDocument(), + makeLoopStopped(), + ]; + const result = partitionInteractiveEvents(events); + expect(result.interactive).toHaveLength(0); + expect(result.loop).toHaveLength(5); + }); + + test("empty input returns empty arrays (defensive — never throws)", () => { + const result = partitionInteractiveEvents([]); + expect(result.interactive).toEqual([]); + expect(result.loop).toEqual([]); + }); + + test("preserves all events — partition is total (interactive ∪ loop = input)", () => { + const events: HistoryEvent[] = [ + makePaSession({ id: "pa1" }), + makeIteration({ id: "iter1" }), + makeReview({ id: "rev1" }), + makePaSession({ id: "pa2" }), + makeReflection({ id: "refl1" }), + makeDocument({ id: "doc1" }), + makeLoopStopped({ id: "stop1" }), + ]; + const result = partitionInteractiveEvents(events); + const allOut = [...result.interactive, ...result.loop].map((e) => e.id).sort(); + const allIn = events.map((e) => e.id).sort(); + expect(allOut).toEqual(allIn); + }); + + test("does not mutate the input array", () => { + const events: HistoryEvent[] = [ + makePaSession({ startedAt: "2026-05-14T10:00:00.000Z" }), + makeIteration({ startedAt: "2026-05-14T08:00:00.000Z" }), + makePaSession({ startedAt: "2026-05-14T12:00:00.000Z" }), + ]; + const originalOrder = events.map((e) => e.startedAt); + partitionInteractiveEvents(events); + expect(events.map((e) => e.startedAt)).toEqual(originalOrder); + }); +}); diff --git a/packages/web/src/utils/history-partition.ts b/packages/web/src/utils/history-partition.ts new file mode 100644 index 0000000..00274a2 --- /dev/null +++ b/packages/web/src/utils/history-partition.ts @@ -0,0 +1,62 @@ +/** + * Partition workspace-history events into interactive-agent events + * (PA today, HA when it gets a history-event type) and everything + * else (the loop's events: iteration, review, document, reflection, + * loop-stopped). + * + * Rationale: PA sessions can run for hours or days. In a single + * chronological list, an active long-running PA's row gets pushed + * deep into the stack as iteration events accumulate above it, + * making the "PA is alive right now" signal hard to scan for. + * + * The two surfaces serve different mental models: + * - Interactive agents: a stable, mostly-empty surface where + * short-and-medium-cardinality PA/HA history lives. Active + * sessions naturally appear at the top because they're the + * newest. + * - Loop history: the chronological audit trail of automated + * iteration work. Unchanged by this partition. + * + * Every event has exactly ONE permanent home — events do NOT move + * between sections based on status. A terminated PA stays in the + * Interactive section (just with `status: "completed"`). This + * preserves history fidelity: nothing disappears or relocates. + * + * Both arrays come back sorted newest-first (descending by + * `startedAt`), matching the existing single-list behaviour. + */ + +import type { HistoryEvent } from "../types"; + +/** + * Event types that belong to the interactive-agents section. Today + * only PA writes history events; HA is ephemeral. When HA grows a + * history-event type, add it here. + */ +export const INTERACTIVE_EVENT_TYPES: ReadonlySet = new Set([ + "pa-session", +]); + +export interface PartitionedHistory { + /** PA + HA events, newest-first. */ + interactive: HistoryEvent[]; + /** Iteration / review / document / reflection / loop-stopped events, newest-first. */ + loop: HistoryEvent[]; +} + +export function partitionInteractiveEvents(events: HistoryEvent[]): PartitionedHistory { + const interactive: HistoryEvent[] = []; + const loop: HistoryEvent[] = []; + for (const e of events) { + if (INTERACTIVE_EVENT_TYPES.has(e.type)) { + interactive.push(e); + } else { + loop.push(e); + } + } + const byStartedDesc = (a: HistoryEvent, b: HistoryEvent) => + new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(); + interactive.sort(byStartedDesc); + loop.sort(byStartedDesc); + return { interactive, loop }; +}