fix(web): gate compaction status by worker state

This commit is contained in:
2026-09-18 00:14:34 +09:00
parent 01bddf2177
commit 6d9c9cdb48
4 changed files with 94 additions and 13 deletions
@@ -5,6 +5,7 @@ import {
formatRunElapsed, formatRunElapsed,
formatRunElapsedCompact, formatRunElapsedCompact,
formatRunTokens, formatRunTokens,
visibleCompactionProgress,
} from "./run-status.ts"; } from "./run-status.ts";
function assertEquals(actual: unknown, expected: unknown): void { 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(3_000), "3.0k");
assertEquals(formatRunTokens(999), "999"); 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);
});
@@ -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 = { export type RunActivityStats = {
startedAtMs: number | null; startedAtMs: number | null;
@@ -71,3 +75,20 @@ export function formatRunTokens(tokens: number): string {
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`; if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
return String(tokens); 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;
}
@@ -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", "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",
);
});
@@ -21,6 +21,7 @@
type ComposerCompletionToken, type ComposerCompletionToken,
} from "$lib/workspace/console/composer-completion"; } from "$lib/workspace/console/composer-completion";
import WorkerRunStatus from "$lib/workspace/console/WorkerRunStatus.svelte"; 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 { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts";
import { import {
consoleWorkerViews, consoleWorkerViews,
@@ -248,18 +249,12 @@
liveWorkerState ?? (worker?.state === "stopped" ? "stopped" : "loading"), liveWorkerState ?? (worker?.state === "stopped" ? "stopped" : "loading"),
); );
const workerRunning = $derived(workerState === "running"); const workerRunning = $derived(workerState === "running");
const compactionProgress = $derived.by(() => { const compactionProgress = $derived(
const state = consoleProjection.workerState?.state; visibleCompactionProgress(
if (!state || typeof state !== "object" || !("busy" in state)) return null; consoleProjection.compaction,
const progress = consoleProjection.compaction; consoleProjection.workerState,
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 workerPaused = $derived(workerState === "paused"); const workerPaused = $derived(workerState === "paused");
const composerEditable = $derived(protocolState === "open" && !sending); const composerEditable = $derived(protocolState === "open" && !sending);
const draftHasText = $derived(draft.content.trim().length > 0); const draftHasText = $derived(draft.content.trim().length > 0);