fix: show Web compacting status for tagged Worker states

This commit is contained in:
2026-09-18 00:31:43 +09:00
6 changed files with 148 additions and 22 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts", "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts",
"build": "deno run -A npm:vite@7.2.7 build", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview" "preview": "deno run -A npm:vite@7.2.7 preview"
}, },
@@ -1,7 +1,14 @@
<script lang="ts"> <script lang="ts">
import type { InFlightCompaction } from "$lib/generated/protocol.ts"; import type {
InFlightCompaction,
WorkerStateSnapshot,
} from "$lib/generated/protocol.ts";
import Spinner from "./Spinner.svelte"; import Spinner from "./Spinner.svelte";
import { formatRunElapsed, formatRunTokens } from "./run-status"; import {
formatRunElapsed,
formatRunTokens,
resolveCompactionStatusPresentation,
} from "./run-status";
type Props = { type Props = {
startedAtMs: number | null; startedAtMs: number | null;
@@ -9,12 +16,24 @@
uploadTokens: number; uploadTokens: number;
outputTokens: number; outputTokens: number;
compaction?: InFlightCompaction | null; compaction?: InFlightCompaction | null;
workerState?: WorkerStateSnapshot | null;
}; };
let { startedAtMs, requests, uploadTokens, outputTokens, compaction = null }: Props = let {
$props(); startedAtMs,
requests,
uploadTokens,
outputTokens,
compaction = null,
workerState = null,
}: Props = $props();
let nowMs = $state(Date.now()); let nowMs = $state(Date.now());
const clockStartedAtMs = $derived(compaction?.started_at_ms ?? startedAtMs); const compactionStatus = $derived(
resolveCompactionStatusPresentation(compaction, workerState),
);
const clockStartedAtMs = $derived(
compactionStatus?.progress.started_at_ms ?? startedAtMs,
);
$effect(() => { $effect(() => {
clockStartedAtMs; clockStartedAtMs;
@@ -33,8 +52,8 @@
<div class="worker-run-status" role="status" aria-live="off"> <div class="worker-run-status" role="status" aria-live="off">
<Spinner /> <Spinner />
{#if compaction} {#if compactionStatus}
<span>Compacting · {compaction.phase}</span> <span>{compactionStatus.label}</span>
<span aria-hidden="true">|</span> <span aria-hidden="true">|</span>
{/if} {/if}
<span>{elapsed}</span> <span>{elapsed}</span>
@@ -5,6 +5,8 @@ import {
formatRunElapsed, formatRunElapsed,
formatRunElapsedCompact, formatRunElapsedCompact,
formatRunTokens, formatRunTokens,
resolveCompactionStatusPresentation,
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 +100,57 @@ 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("WorkerRunStatus presentation follows compaction and busy 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);
assertEquals(resolveCompactionStatusPresentation(manual, maintenance), {
progress: manual,
label: "Compacting · preparing",
});
assertEquals(resolveCompactionStatusPresentation(automatic, run), {
progress: automatic,
label: "Compacting · summarizing",
});
assertEquals(resolveCompactionStatusPresentation(manual, run), null);
assertEquals(
resolveCompactionStatusPresentation(automatic, maintenance),
null,
);
assertEquals(resolveCompactionStatusPresentation(manual, idle), 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,35 @@ 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 type CompactionStatusPresentation = {
progress: InFlightCompaction;
label: string;
};
export function resolveCompactionStatusPresentation(
compaction: InFlightCompaction | null,
workerState: WorkerStateSnapshot | null,
): CompactionStatusPresentation | null {
const progress = visibleCompactionProgress(compaction, workerState);
return progress
? { progress, label: `Compacting · ${progress.phase}` }
: null;
}
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,29 @@ 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("compaction={consoleProjection.compaction}") &&
consolePage.includes("workerState={consoleProjection.workerState}"),
"Console page should pass compaction metadata with authoritative worker state to the status component",
);
assert(
runStatus.includes("resolveCompactionStatusPresentation(") &&
runStatus.includes("{compactionStatus.label}") &&
runStatus.includes(
"compactionStatus?.progress.started_at_ms ?? startedAtMs",
),
"run status should render the guarded presentation and derive elapsed time from its authoritative start timestamp",
);
});
@@ -248,18 +248,6 @@
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 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 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);
@@ -1917,7 +1905,8 @@
requests={consoleProjection.runActivity.requests} requests={consoleProjection.runActivity.requests}
uploadTokens={consoleProjection.runActivity.uploadTokens} uploadTokens={consoleProjection.runActivity.uploadTokens}
outputTokens={consoleProjection.runActivity.outputTokens} outputTokens={consoleProjection.runActivity.outputTokens}
compaction={compactionProgress} compaction={consoleProjection.compaction}
workerState={consoleProjection.workerState}
/> />
{/if} {/if}