diff --git a/extensions/workflows/dashboard.ts b/extensions/workflows/dashboard.ts index 2a9b34ef..c2b76b2b 100644 --- a/extensions/workflows/dashboard.ts +++ b/extensions/workflows/dashboard.ts @@ -62,11 +62,16 @@ import { type WorkflowLogEntry, workflowGraphRecords, } from "./model.ts"; +import { measureWorkflowDetailsBytes } from "./retention.ts"; import { writeFileAtomic } from "./serialization.ts"; import { WorkflowTranscriptAdapter } from "./transcript.ts"; const NOTICE_TTL_MS = 4000; const MIN_HEIGHT = 10; +// This is a UI projection bound. It is deliberately independent from the +// session-memory settled-run retention policy; disk remains canonical. +const DEFAULT_WORKFLOW_DASHBOARD_MAX_RUNS = 32; +const DEFAULT_WORKFLOW_DASHBOARD_MAX_BYTES = 2 * 1024 * 1024; function wrapSelection(index: number, delta: number, length: number): number { if (length === 0) return 0; @@ -79,6 +84,23 @@ export interface RunEntry { live: boolean; } +export interface RunEntryLoadOptions { + /** Explicitly opened runs remain visible even when the list is bounded. */ + initialRunId?: string; + /** Maximum number of non-pinned persisted entries kept in the list. */ + maxRuns?: number; + /** Maximum serialized UTF-8 bytes kept by non-pinned persisted entries. */ + maxBytes?: number; +} + +export interface RunEntryLoadResult { + entries: RunEntry[]; + /** Persisted entries omitted by the dashboard projection bound. */ + omittedRuns: number; + /** Serialized bytes belonging to omitted persisted entries. */ + omittedBytes: number; +} + function runsDir(): string { return path.join(getAgentDir(), "workflows"); } @@ -563,7 +585,36 @@ export function sessionWorkflowRunIds(ctx: ExtensionContext): Set { return runIds; } -export function loadRunEntries( +function configuredLimit( + value: number | undefined, + fallback: number, + name: string, +) { + if (value === undefined) return fallback; + if (!Number.isSafeInteger(value) || value < 0) + throw new RangeError(`${name} must be a non-negative safe integer`); + return value; +} + +function compareRunEntries(a: RunEntry, b: RunEntry) { + return ( + b.details.startedAt - a.details.startedAt || a.runId.localeCompare(b.runId) + ); +} + +function measureRunEntryBytes(details: WorkflowDetails) { + try { + return measureWorkflowDetailsBytes(details); + } catch { + return undefined; + } +} + +/** + * Build the bounded dashboard list without hydrating result/transcript + * artifacts. Details are loaded lazily when the user opens a run. + */ +export function loadRunEntryProjection( active: Map, sessionId: string, referencedRunIds: ReadonlySet, @@ -571,17 +622,64 @@ export function loadRunEntries( startedSince = 0, /** Bounded settled projections used only if canonical disk state is unreadable. */ retained: ReadonlyMap = new Map(), -): RunEntry[] { - const entries: RunEntry[] = []; - const runIds = new Set([...listPersistedRunIds(), ...retained.keys()]); + options: RunEntryLoadOptions = {}, +): RunEntryLoadResult { + const maxRuns = configuredLimit( + options.maxRuns, + DEFAULT_WORKFLOW_DASHBOARD_MAX_RUNS, + "maxRuns", + ); + const maxBytes = configuredLimit( + options.maxBytes, + DEFAULT_WORKFLOW_DASHBOARD_MAX_BYTES, + "maxBytes", + ); + const pinned = new Map(); + const bounded: { entry: RunEntry; bytes: number }[] = []; + let boundedBytes = 0; + let omittedRuns = 0; + let omittedBytes = 0; + + const omit = (bytes: number | undefined) => { + omittedRuns++; + if (bytes !== undefined) omittedBytes += bytes; + }; + + const addBounded = (entry: RunEntry) => { + const bytes = measureRunEntryBytes(entry.details); + if (bytes === undefined) { + omit(undefined); + return; + } + bounded.push({ entry, bytes }); + bounded.sort((a, b) => compareRunEntries(a.entry, b.entry)); + boundedBytes += bytes; + while (bounded.length > maxRuns || boundedBytes > maxBytes) { + const oldest = bounded.pop(); + if (!oldest) break; + boundedBytes -= oldest.bytes; + omit(oldest.bytes); + } + }; + + const addEntry = (entry: RunEntry, keepPinned: boolean) => { + if (keepPinned) pinned.set(entry.runId, entry); + else addBounded(entry); + }; + + const runIds = new Set([ + ...listPersistedRunIds(), + ...active.keys(), + ...retained.keys(), + ]); for (const runId of runIds) { const live = active.get(runId); if (live) { - entries.push({ runId, details: live, live: true }); + addEntry({ runId, details: live, live: true }, true); continue; } const persisted = readPersistedWorkflowDetails(runId, { - hydrateArtifacts: true, + hydrateArtifacts: false, }); const retainedDetails = retained.get(runId); const details = persisted ?? retainedDetails; @@ -597,10 +695,43 @@ export function loadRunEntries( ) { continue; } + // A session reference makes a cross-session run eligible, but it remains + // subject to the dashboard bound. Only active runs and the explicit target + // are pinned so a long session cannot defeat count/byte limits. recoverStaleWorkflowDetails(details); - entries.push({ runId, details, live: false }); + addEntry( + { runId, details, live: false }, + options.initialRunId !== undefined && + runId.toLowerCase() === options.initialRunId.toLowerCase(), + ); } - return entries.sort((a, b) => b.details.startedAt - a.details.startedAt); + + return { + entries: [ + ...pinned.values(), + ...bounded.map((candidate) => candidate.entry), + ].sort(compareRunEntries), + omittedRuns, + omittedBytes, + }; +} + +export function loadRunEntries( + active: Map, + sessionId: string, + referencedRunIds: ReadonlySet, + startedSince = 0, + retained: ReadonlyMap = new Map(), + options: RunEntryLoadOptions = {}, +): RunEntry[] { + return loadRunEntryProjection( + active, + sessionId, + referencedRunIds, + startedSince, + retained, + options, + ).entries; } export function workflowGraphSummary( @@ -717,6 +848,7 @@ export class WorkflowDashboard { private transcriptPage?: AgentSessionPage; private current?: RunEntry; private openedDirectly = false; + private omittedRuns = 0; private notice?: string; private noticeAt = 0; private disposed = false; @@ -732,6 +864,7 @@ export class WorkflowDashboard { private close: () => void; private onAbort?: (runId: string) => boolean; private initialToolsExpanded: boolean; + private initialRunId?: string; constructor( tui: TUI, @@ -758,22 +891,31 @@ export class WorkflowDashboard { this.close = close; this.onAbort = onAbort; this.initialToolsExpanded = initialToolsExpanded; + const initialResolution = initialRunId + ? resolveWorkflowRunTarget(initialRunId, [ + ...listPersistedRunIds(), + ...this.getActive().keys(), + ...this.getRetained().keys(), + ]) + : undefined; + this.initialRunId = initialResolution?.ok + ? initialResolution.runId + : undefined; this.refresh(); - if (initialRunId) { - const resolution = resolveWorkflowRunTarget( - initialRunId, - this.entries.map((entry) => entry.runId), - ); - if (resolution.ok) { + if (initialResolution) { + if (initialResolution.ok) { const entry = this.entries.find( - (candidate) => candidate.runId === resolution.runId, + (candidate) => candidate.runId === initialResolution.runId, ); if (entry) { this.listIndex = this.entries.indexOf(entry); this.enterEntry(entry, true); + } else { + this.notice = `Workflow run ${initialResolution.runId} could not be read.`; + this.noticeAt = Date.now(); } } else { - this.notice = resolution.error; + this.notice = initialResolution.error; this.noticeAt = Date.now(); } } @@ -818,13 +960,20 @@ export class WorkflowDashboard { private refresh() { const selected = this.entries[this.listIndex]?.runId; - this.entries = loadRunEntries( + const pinnedRunId = + this.view === "list" + ? this.initialRunId + : (this.current?.runId ?? this.initialRunId); + const projection = loadRunEntryProjection( this.getActive(), this.sessionId, this.referencedRunIds, this.startedSince, this.getRetained(), + { initialRunId: pinnedRunId }, ); + this.entries = projection.entries; + this.omittedRuns = projection.omittedRuns; if (selected) { const index = this.entries.findIndex((e) => e.runId === selected); if (index >= 0) this.listIndex = index; @@ -837,18 +986,47 @@ export class WorkflowDashboard { const refreshed = this.entries.find( (e) => e.runId === this.current?.runId, ); - if (refreshed) this.current = refreshed; + if (refreshed) { + if (!this.current.live && !refreshed.live) { + // Keep the selected detail's already-hydrated artifacts while the + // list projection is refreshed from compact metadata. + this.current = { ...refreshed, details: this.current.details }; + } else if (this.current.live && !refreshed.live) { + // A live run may settle while its detail view is open. Hydrate the + // newly canonical state once so the transcript does not disappear. + const details = + readPersistedWorkflowDetails(refreshed.runId, { + hydrateArtifacts: true, + }) ?? refreshed.details; + this.current = { + ...refreshed, + details: recoverStaleWorkflowDetails(details), + }; + } else { + this.current = refreshed; + } + } } if (this.notice && Date.now() - this.noticeAt > NOTICE_TTL_MS) this.notice = undefined; } private enterEntry(entry: RunEntry, directly: boolean) { - this.current = entry; + const details = entry.live + ? entry.details + : recoverStaleWorkflowDetails( + readPersistedWorkflowDetails(entry.runId, { + hydrateArtifacts: true, + }) ?? entry.details, + ); + this.current = { + ...entry, + details, + }; this.openedDirectly = directly; - const groups = phaseGroups(entry.details, true); + const groups = phaseGroups(this.current.details, true); const currentPhase = groups.findIndex( - (group) => group.title === entry.details.currentPhase, + (group) => group.title === this.current?.details.currentPhase, ); this.phaseIndex = Math.max(0, currentPhase); this.agentIndex = 0; @@ -1096,16 +1274,24 @@ export class WorkflowDashboard { private renderList(width: number, height: number): string[] { const theme = this.theme; const lines: string[] = []; + const omittedNotice = + this.omittedRuns > 0 + ? theme.fg( + "dim", + `${this.omittedRuns} run${this.omittedRuns === 1 ? "" : "s"} omitted from this view; full artifacts remain on disk.`, + ) + : undefined; lines.push( screenTitleLine( theme, "Workflows", - `${this.entries.length} run${this.entries.length === 1 ? "" : "s"}`, + `${this.entries.length} run${this.entries.length === 1 ? "" : "s"}${this.omittedRuns > 0 ? ` ยท ${this.omittedRuns} omitted` : ""}`, width, ), ); + if (omittedNotice) lines.push(omittedNotice); - const panelHeight = height - 2; + const panelHeight = Math.max(1, height - 2 - (omittedNotice ? 1 : 0)); const bodyHeight = Math.max(0, panelHeight - 2); if (this.entries.length === 0) { diff --git a/extensions/workflows/model.ts b/extensions/workflows/model.ts index dcb4f102..e3f023d7 100644 --- a/extensions/workflows/model.ts +++ b/extensions/workflows/model.ts @@ -312,11 +312,29 @@ export function resolveWorkflowRunTarget( const normalizedTarget = trimmed.toLowerCase(); const runIds = [...new Set([...candidates].filter(isWorkflowRunId))].sort(); - if (isWorkflowRunId(normalizedTarget) && runIds.includes(normalizedTarget)) { - return { ok: true, runId: normalizedTarget } as const; + const normalizedRunIds = runIds.map((runId) => ({ + runId, + normalized: runId.toLowerCase(), + })); + if (isWorkflowRunId(normalizedTarget)) { + const exactCase = runIds.find((runId) => runId === trimmed); + if (exactCase) return { ok: true, runId: exactCase } as const; + const exactMatches = normalizedRunIds.filter( + (candidate) => candidate.normalized === normalizedTarget, + ); + if (exactMatches.length === 1) + return { ok: true, runId: exactMatches[0]!.runId } as const; + if (exactMatches.length > 1) { + return { + ok: false, + error: `Workflow run suffix "${sanitizeLine(trimmed, 80)}" is ambiguous. Matches: ${boundedRunList(exactMatches.map((candidate) => candidate.runId))}. Use a longer suffix or full run id.`, + } as const; + } } - const matches = runIds.filter((runId) => runId.endsWith(normalizedTarget)); + const matches = normalizedRunIds + .filter((candidate) => candidate.normalized.endsWith(normalizedTarget)) + .map((candidate) => candidate.runId); if (matches.length === 1) return { ok: true, runId: matches[0]! } as const; const displayTarget = sanitizeLine(trimmed, 80); diff --git a/tests/extensions/workflows/dashboard.test.ts b/tests/extensions/workflows/dashboard.test.ts index 77ebbfc1..10a7aa4c 100644 --- a/tests/extensions/workflows/dashboard.test.ts +++ b/tests/extensions/workflows/dashboard.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + rmSync, statSync, writeFileSync, } from "node:fs"; @@ -31,6 +32,7 @@ initTheme("dark", false); const { buildWorkflowReport, loadRunEntries, + loadRunEntryProjection, normalizePersistedWorkflowDetails, recoverStaleWorkflowDetails, workflowGraphSummary, @@ -278,6 +280,69 @@ test("stale pre-V2 runs gain a stable delivery identity while terminal legacy ru assert.equal(terminal.delivery, undefined); }); +test("opening a live run does not recover or mutate canonical details", () => { + const runId = "wf_deadface"; + const startedAt = Date.now(); + const details: WorkflowDetails = { + runId, + sessionId: SESSION, + name: "live run", + background: true, + status: "running", + startedAt, + phases: [{ title: "work" }], + currentPhase: "work", + agents: [ + { + index: 1, + label: "worker", + phase: "work", + state: "running", + startedAt, + preview: "working", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + turns: 1, + }, + transcript: [], + }, + ], + }; + const active = new Map([[runId, details]]); + const dashboard = new WorkflowDashboard( + { terminal: { rows: 20 }, requestRender() {} } as unknown as TUI, + { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + } as unknown as Theme, + { + matches: () => false, + getKeys: () => ["esc"], + } as unknown as KeybindingsManager, + () => active, + SESSION, + new Set(), + startedAt, + () => {}, + ); + + try { + dashboard.handleInput("l"); + assert.equal(details.status, "running"); + assert.equal(details.finishedAt, undefined); + assert.equal(details.error, undefined); + assert.equal(details.delivery, undefined); + assert.equal(details.agents[0]?.state, "running"); + assert.equal(details.agents[0]?.finishedAt, undefined); + } finally { + dashboard.dispose(); + } +}); + test("persisted usage is normalized to finite nonnegative numbers", () => { const details = normalizePersistedWorkflowDetails("wf_usage", { status: "completed", @@ -366,6 +431,455 @@ test("the dashboard reports the current request, not the session's history", () ); }); +test("dashboard list projection leaves side artifacts unloaded", () => { + const runId = "wf_face"; + const startedAt = Date.now(); + const dir = join(agentDir, "workflows", runId); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "workflow.json"), + JSON.stringify({ + runId, + sessionId: SESSION, + status: "completed", + startedAt, + finishedAt: startedAt + 1_000, + phases: [], + agents: [ + { + index: 0, + label: "worker", + state: "done", + startedAt: 8_100, + finishedAt: 8_900, + preview: "compact preview", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + turns: 1, + }, + }, + ], + result: "[stored in result.json]", + resultArtifact: "result.json", + transcriptArtifact: "transcripts.json", + }), + ); + writeFileSync( + join(dir, "result.json"), + JSON.stringify({ full: "result payload" }), + ); + writeFileSync( + join(dir, "transcripts.json"), + JSON.stringify({ 0: [{ role: "assistant", text: "full transcript" }] }), + ); + + try { + const entry = loadRunEntries(new Map(), SESSION, new Set()).find( + (candidate) => candidate.runId === runId, + ); + assert.equal(entry?.details.result, "[stored in result.json]"); + assert.deepEqual(entry?.details.agents[0]?.transcript, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("dashboard projection bounds unpinned history and keeps an explicit run", () => { + const oldest = "wf_a001"; + const middle = "wf_b002"; + const newest = "wf_c003"; + const startedAt = Date.now() + 20_000; + writeRun(oldest, startedAt); + writeRun(middle, startedAt + 1_000); + writeRun(newest, startedAt + 2_000); + + try { + const projection = loadRunEntryProjection( + new Map(), + SESSION, + new Set(), + startedAt, + new Map(), + { initialRunId: oldest, maxRuns: 1, maxBytes: 1024 * 1024 }, + ); + + assert.deepEqual( + projection.entries.map((entry) => entry.runId), + [newest, oldest], + ); + assert.equal(projection.omittedRuns, 1); + assert.ok(projection.omittedBytes > 0); + } finally { + for (const runId of [oldest, middle, newest]) + rmSync(join(agentDir, "workflows", runId), { + recursive: true, + force: true, + }); + } +}); + +test("dashboard target resolution includes omitted runs when a suffix is ambiguous", () => { + const startedAt = Date.now() + 40_000; + const runIds = ["wf_0000beef", "wf_ffffbeef"]; + for (let index = 0; index < 31; index++) { + runIds.push(`wf_${(index + 1).toString(16).padStart(4, "0")}cafe`); + } + for (const [index, runId] of runIds.entries()) + writeRun(runId, startedAt + index); + + const dashboard = new WorkflowDashboard( + { terminal: { rows: 20 }, requestRender() {} } as unknown as TUI, + { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + } as unknown as Theme, + { + matches: () => false, + getKeys: () => ["esc"], + } as unknown as KeybindingsManager, + () => new Map(), + SESSION, + new Set(), + startedAt, + () => {}, + "beef", + ); + try { + const rendered = dashboard.render(120).join("\n"); + assert.match(rendered, /ambiguous/i); + assert.doesNotMatch(rendered, /Phases/); + } finally { + dashboard.dispose(); + for (const runId of runIds) + rmSync(join(agentDir, "workflows", runId), { + recursive: true, + force: true, + }); + } +}); + +test("referenced history still obeys the dashboard projection bound", () => { + const startedAt = Date.now() + 50_000; + const runIds = ["wf_f00a", "wf_f00b", "wf_f00c"]; + for (const [index, runId] of runIds.entries()) + writeRun(runId, startedAt + index); + + try { + const projection = loadRunEntryProjection( + new Map(), + SESSION, + new Set(runIds), + startedAt, + new Map(), + { maxRuns: 1, maxBytes: 1024 * 1024 }, + ); + assert.equal(projection.entries.length, 1); + assert.equal(projection.omittedRuns, 2); + } finally { + for (const runId of runIds) + rmSync(join(agentDir, "workflows", runId), { + recursive: true, + force: true, + }); + } +}); + +test("dashboard pinning matches run ids case-insensitively", () => { + const runId = "wf_ABCD"; + const startedAt = Date.now() + 60_000; + writeRun(runId, startedAt); + try { + const projection = loadRunEntryProjection( + new Map(), + SESSION, + new Set(), + startedAt, + new Map(), + { initialRunId: "WF_ABCD", maxRuns: 0, maxBytes: 0 }, + ); + assert.deepEqual( + projection.entries.map((entry) => entry.runId), + [runId], + ); + assert.equal(projection.omittedRuns, 0); + } finally { + rmSync(join(agentDir, "workflows", runId), { + recursive: true, + force: true, + }); + } +}); + +test("dashboard hydrates a persisted run only when its detail view opens", () => { + const runId = "wf_dada"; + const startedAt = Date.now() + 20_000; + const dir = join(agentDir, "workflows", runId); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "workflow.json"), + JSON.stringify({ + runId, + sessionId: SESSION, + name: "lazy details", + status: "completed", + startedAt, + finishedAt: startedAt + 1_000, + phases: [{ title: "work" }], + agents: [ + { + index: 0, + label: "worker", + phase: "work", + state: "done", + startedAt: startedAt + 100, + finishedAt: startedAt + 900, + preview: "compact preview", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + turns: 1, + }, + }, + ], + result: "[stored in result.json]", + resultArtifact: "result.json", + transcriptArtifact: "transcripts.json", + }), + ); + writeFileSync( + join(dir, "result.json"), + JSON.stringify({ full: "result payload" }), + ); + writeFileSync( + join(dir, "transcripts.json"), + JSON.stringify({ 0: [{ role: "assistant", text: "full transcript" }] }), + ); + + const dashboard = new WorkflowDashboard( + { terminal: { rows: 60 }, requestRender() {} } as unknown as TUI, + { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + } as unknown as Theme, + { + matches: () => false, + getKeys: () => ["esc"], + } as unknown as KeybindingsManager, + () => new Map(), + SESSION, + new Set(), + startedAt, + () => {}, + ); + try { + const list = dashboard.render(120).join("\n"); + assert.match(list, /lazy details/); + assert.doesNotMatch(list, /result payload|full transcript/); + + dashboard.handleInput("l"); + dashboard.handleInput("l"); + dashboard.handleInput("l"); + const transcript = dashboard.render(120).join("\n"); + assert.match(transcript, /full transcript/); + } finally { + dashboard.dispose(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("dashboard exposes omitted history in its list projection", () => { + const startedAt = Date.now() + 30_000; + const runIds: string[] = []; + for (let index = 0; index < 33; index++) { + const runId = `wf_${(index + 1).toString(16).padStart(4, "0")}`; + runIds.push(runId); + writeRun(runId, startedAt + index); + } + + const dashboard = new WorkflowDashboard( + { terminal: { rows: 20 }, requestRender() {} } as unknown as TUI, + { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + } as unknown as Theme, + { + matches: () => false, + getKeys: () => ["esc"], + } as unknown as KeybindingsManager, + () => new Map(), + SESSION, + new Set(), + startedAt, + () => {}, + ); + try { + const rendered = dashboard.render(120).join("\n"); + assert.match(rendered, /1 omitted/); + assert.match(rendered, /full artifacts remain on disk/); + + const direct = new WorkflowDashboard( + { terminal: { rows: 20 }, requestRender() {} } as unknown as TUI, + { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + } as unknown as Theme, + { + matches: () => false, + getKeys: () => ["esc"], + } as unknown as KeybindingsManager, + () => new Map(), + SESSION, + new Set(), + startedAt, + () => {}, + runIds[0], + ); + try { + assert.match(direct.render(120).join("\n"), new RegExp(runIds[0])); + } finally { + direct.dispose(); + } + } finally { + dashboard.dispose(); + for (const runId of runIds) + rmSync(join(agentDir, "workflows", runId), { + recursive: true, + force: true, + }); + } +}); + +test("the open live detail stays pinned and hydrates after it settles", async () => { + const currentRunId = "wf_deadbeef"; + const startedAt = Date.now() + 70_000; + const historyRunIds = Array.from( + { length: 32 }, + (_, index) => `wf_${(index + 1).toString(16).padStart(8, "0")}`, + ); + for (const [index, runId] of historyRunIds.entries()) + writeRun(runId, startedAt + index + 1); + + const liveDetails: WorkflowDetails = { + runId: currentRunId, + sessionId: SESSION, + name: "live current", + background: true, + status: "running", + startedAt, + phases: [{ title: "work" }], + currentPhase: "work", + agents: [ + { + index: 1, + label: "worker", + phase: "work", + state: "running", + startedAt, + preview: "live preview", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + turns: 1, + }, + transcript: [{ role: "assistant", text: "live transcript" }], + }, + ], + }; + const active = new Map([[currentRunId, liveDetails]]); + const dashboard = new WorkflowDashboard( + { terminal: { rows: 30 }, requestRender() {} } as unknown as TUI, + { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + } as unknown as Theme, + { + matches: () => false, + getKeys: () => ["esc"], + } as unknown as KeybindingsManager, + () => active, + SESSION, + new Set(), + startedAt, + () => {}, + ); + const currentDir = join(agentDir, "workflows", currentRunId); + + try { + dashboard.handleInput("G"); + dashboard.handleInput("l"); + + active.delete(currentRunId); + mkdirSync(currentDir, { recursive: true }); + writeFileSync( + join(currentDir, "workflow.json"), + JSON.stringify({ + runId: currentRunId, + sessionId: SESSION, + name: "live current", + status: "completed", + startedAt, + finishedAt: startedAt + 1_000, + phases: [{ title: "work" }], + agents: [ + { + index: 1, + label: "worker", + phase: "work", + state: "done", + startedAt, + finishedAt: startedAt + 900, + preview: "settled preview", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + turns: 1, + }, + }, + ], + transcriptArtifact: "transcripts.json", + }), + ); + writeFileSync( + join(currentDir, "transcripts.json"), + JSON.stringify({ + 1: [{ role: "assistant", text: "hydrated transcript" }], + }), + ); + + // The live dashboard refresh timer observes the settlement. The current + // run is older than the 32 newer history entries and would otherwise be + // omitted from the bounded projection. + await new Promise((resolve) => + setTimeout(resolve, SPINNER_INTERVAL_MS * 2 + 20), + ); + dashboard.handleInput("l"); + dashboard.handleInput("l"); + const transcript = dashboard.render(120).join("\n"); + assert.match(transcript, /hydrated transcript/); + } finally { + dashboard.dispose(); + rmSync(currentDir, { recursive: true, force: true }); + for (const runId of historyRunIds) + rmSync(join(agentDir, "workflows", runId), { + recursive: true, + force: true, + }); + } +}); + test("retained projections keep a settled run visible when disk state is unreadable", () => { const runId = "wf_fa11bac"; const details = retainedRun(runId, 6_000); diff --git a/tests/extensions/workflows/target-resolution.test.ts b/tests/extensions/workflows/target-resolution.test.ts index 207b2f7a..d5a4f175 100644 --- a/tests/extensions/workflows/target-resolution.test.ts +++ b/tests/extensions/workflows/target-resolution.test.ts @@ -43,6 +43,13 @@ test("run target resolution prefers exact ids and bounds ambiguous or missing er runId: "wf_01a", }); + const caseCollision = resolveWorkflowRunTarget("Wf_AbCd", [ + "wf_abcd", + "wf_ABCD", + ]); + assert.equal(caseCollision.ok, false); + assert.match(caseCollision.error, /ambiguous/i); + const ambiguous = resolveWorkflowRunTarget("a", candidates); assert.equal(ambiguous.ok, false); assert.match(ambiguous.error, /ambiguous/i);