diff --git a/web/workspace/src/lib/workspace/console/run-status.test.ts b/web/workspace/src/lib/workspace/console/run-status.test.ts index e0d990471..998009b61 100644 --- a/web/workspace/src/lib/workspace/console/run-status.test.ts +++ b/web/workspace/src/lib/workspace/console/run-status.test.ts @@ -5,6 +5,7 @@ import { formatRunElapsed, formatRunElapsedCompact, formatRunTokens, + visibleCompactionProgress, } from "./run-status.ts"; function assertEquals(actual: unknown, expected: unknown): void { @@ -98,3 +99,42 @@ Deno.test("run status formatting matches the compact TUI shape", () => { assertEquals(formatRunTokens(3_000), "3.0k"); assertEquals(formatRunTokens(999), "999"); }); + +Deno.test("visible compaction requires a matching busy worker state", () => { + const manual = { + phase: "preparing", + started_at_ms: 1_700_000_000_000, + trigger: "manual", + } as const; + const automatic = { + phase: "summarizing", + started_at_ms: 1_700_000_000_000, + trigger: "request_threshold", + } as const; + const maintenance = { + last_command_id: 7, + state: { + kind: "busy", + state: { kind: "maintenance", state: "compacting" }, + }, + } as const; + const run = { + last_command_id: 7, + state: { + kind: "busy", + state: { kind: "run", state: "running" }, + }, + } as const; + const idle = { + last_command_id: 7, + state: { kind: "idle" }, + } as const; + + assertEquals(visibleCompactionProgress(manual, maintenance), manual); + assertEquals(visibleCompactionProgress(automatic, run), automatic); + assertEquals(visibleCompactionProgress(manual, run), null); + assertEquals(visibleCompactionProgress(automatic, maintenance), null); + assertEquals(visibleCompactionProgress(manual, idle), null); + assertEquals(visibleCompactionProgress(manual, null), null); + assertEquals(visibleCompactionProgress(null, maintenance), null); +}); diff --git a/web/workspace/src/lib/workspace/console/run-status.ts b/web/workspace/src/lib/workspace/console/run-status.ts index 37495dc1c..326694e97 100644 --- a/web/workspace/src/lib/workspace/console/run-status.ts +++ b/web/workspace/src/lib/workspace/console/run-status.ts @@ -1,4 +1,8 @@ -import type { Event as ProtocolEvent } from "$lib/generated/protocol"; +import type { + Event as ProtocolEvent, + InFlightCompaction, + WorkerStateSnapshot, +} from "$lib/generated/protocol"; export type RunActivityStats = { startedAtMs: number | null; @@ -71,3 +75,20 @@ export function formatRunTokens(tokens: number): string { if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`; return String(tokens); } + +export function visibleCompactionProgress( + compaction: InFlightCompaction | null, + workerState: WorkerStateSnapshot | null, +): InFlightCompaction | null { + if (!compaction || workerState?.state.kind !== "busy") { + return null; + } + + const busyState = workerState.state.state; + const triggerMatches = compaction.trigger === "manual" + ? busyState.kind === "maintenance" && + busyState.state === "compacting" + : busyState.kind === "run"; + + return triggerMatches ? compaction : null; +} diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 3b6629ae9..c39f231a9 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -1116,3 +1116,28 @@ Deno.test("Web Console uses Notify while running and exposes durable pending con "ordinary text must remain Submit instead of being implicitly converted to Notify", ); }); + +Deno.test("Web Console gates compaction progress before rendering run status", async () => { + const consolePage = await Deno.readTextFile( + new URL( + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + import.meta.url, + ), + ); + const runStatus = await Deno.readTextFile( + new URL("./WorkerRunStatus.svelte", import.meta.url), + ); + + assert( + consolePage.includes("visibleCompactionProgress(") && + consolePage.includes("consoleProjection.compaction") && + consolePage.includes("consoleProjection.workerState") && + consolePage.includes("compaction={compactionProgress}"), + "Console page should pass only state-consistent compaction progress to the status component", + ); + assert( + runStatus.includes("compaction?.started_at_ms ?? startedAtMs") && + runStatus.includes("Compacting ยท {compaction.phase}"), + "run status should render the compaction phase and derive elapsed time from the authoritative start timestamp", + ); +}); diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte index 28f3cbd35..b9ba03797 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte @@ -21,6 +21,7 @@ type ComposerCompletionToken, } from "$lib/workspace/console/composer-completion"; import WorkerRunStatus from "$lib/workspace/console/WorkerRunStatus.svelte"; + import { visibleCompactionProgress } from "$lib/workspace/console/run-status"; import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts"; import { consoleWorkerViews, @@ -248,18 +249,12 @@ liveWorkerState ?? (worker?.state === "stopped" ? "stopped" : "loading"), ); const workerRunning = $derived(workerState === "running"); - const compactionProgress = $derived.by(() => { - const state = consoleProjection.workerState?.state; - if (!state || typeof state !== "object" || !("busy" in state)) return null; - const progress = consoleProjection.compaction; - if (!progress) return null; - const busy = state.busy; - if (!busy || typeof busy !== "object") return null; - const valid = progress.trigger === "manual" - ? "maintenance" in busy && busy.maintenance === "compacting" - : "run" in busy; - return valid ? progress : null; - }); + const compactionProgress = $derived( + visibleCompactionProgress( + consoleProjection.compaction, + consoleProjection.workerState, + ), + ); const workerPaused = $derived(workerState === "paused"); const composerEditable = $derived(protocolState === "open" && !sending); const draftHasText = $derived(draft.content.trim().length > 0);