merge: worker console redesign

# Conflicts:
#	web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte
This commit is contained in:
2026-06-27 03:26:48 +09:00
17 changed files with 1956 additions and 317 deletions
@@ -0,0 +1,181 @@
import type { Event } from "$lib/generated/protocol";
import { projectConsole, segmentsToText, workerConsoleHref } from "./model.ts";
declare const Deno: {
test(name: string, fn: () => void): void;
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
Deno.test("workerConsoleHref encodes runtime and worker target authority", () => {
assert(
workerConsoleHref({
runtime_id: "local runtime",
worker_id: "worker/one",
}) ===
"/runtimes/local%20runtime/workers/worker%2Fone/console",
"href should contain encoded runtime_id and worker_id segments",
);
});
Deno.test("segmentsToText preserves protocol segment semantics", () => {
const text = segmentsToText([
{ kind: "text", content: "hello" },
{ kind: "file_ref", path: "/tmp/example.md" },
{ kind: "knowledge_ref", slug: "design-note" },
{ kind: "workflow_invoke", slug: "ticket-review" },
]);
assert(text.includes("hello"), "text segment should render content");
assert(
text.includes("@file /tmp/example.md"),
"file ref should render as a file reference",
);
assert(
text.includes("@knowledge design-note"),
"knowledge ref should render as a knowledge reference",
);
assert(
text.includes("/ticket-review"),
"workflow invocation should render as slash command",
);
});
Deno.test("projectConsole keeps transcript and protocol-derived event rows distinct", () => {
const projection = projectConsole(
[
{
sequence: 1,
role: "user",
content: "transcript input",
event_id: 10,
},
],
[
{
cursor: "11",
event: {
event: "text_delta",
data: { text: "stream" },
} satisfies Event,
},
{
cursor: "12",
event: {
event: "thinking_done",
data: { text: "reasoning" },
} satisfies Event,
},
{
cursor: "13",
event: {
event: "tool_result",
data: {
id: "tool-1",
summary: "read file",
output: "content",
is_error: false,
},
} satisfies Event,
},
{
cursor: "14",
event: {
event: "usage",
data: { input_tokens: 12, output_tokens: 5 },
} satisfies Event,
},
{
cursor: "15",
event: {
event: "error",
data: { code: "invalid_request", message: "bad frame" },
} satisfies Event,
},
],
);
assert(
projection.lines.some((line) =>
line.source === "transcript" && line.kind === "user"
),
"transcript user row expected",
);
assert(
projection.lines.some((line) =>
line.source === "event" && line.kind === "assistant"
),
"assistant event row expected",
);
assert(
projection.lines.some((line) => line.kind === "thinking"),
"thinking event row expected",
);
assert(
projection.lines.some((line) => line.kind === "tool"),
"tool event row expected",
);
assert(
projection.lines.some((line) => line.kind === "usage"),
"usage event row expected",
);
assert(
projection.lines.some((line) => line.kind === "error" && line.error),
"error event row expected",
);
assert(
projection.usage === "input 12 · output 5 · cache unknown",
"usage summary should be retained",
);
});
Deno.test("projectConsole displays snapshot and in-flight state", () => {
const projection = projectConsole([], [
{
cursor: "20",
event: {
event: "snapshot",
data: {
entries: [{ role: "user" }],
greeting: {
worker_name: "Worker",
cwd: "/repo",
provider: "provider",
model: "model",
scope_summary: "bounded",
tools: ["Read"],
context_window: 100,
context_tokens: 20,
},
status: "running",
in_flight: {
blocks: [
{ kind: "text", text: "unfinished answer", finished: false },
{
kind: "tool_call",
id: "call-1",
name: "Read",
args: "{}",
state: "streaming_args",
},
],
},
},
} satisfies Event,
},
]);
assert(projection.status === "running", "snapshot should update status");
assert(
projection.lines.some((line) => line.kind === "snapshot"),
"snapshot row expected",
);
assert(
projection.lines.filter((line) => line.kind === "in_flight").length === 2,
"in-flight rows expected",
);
});
@@ -0,0 +1,598 @@
import type {
Event as ProtocolEvent,
InFlightBlock,
Segment,
} from "$lib/generated/protocol";
import type { WorkerTranscriptItem } from "$lib/workspace-sidebar/types";
export type ConsoleLineKind =
| "user"
| "assistant"
| "thinking"
| "tool"
| "status"
| "error"
| "usage"
| "snapshot"
| "in_flight"
| "system";
export type ConsoleLine = {
id: string;
kind: ConsoleLineKind;
title: string;
body: string;
detail?: string;
cursor?: string | null;
source: "transcript" | "event";
streaming?: boolean;
error?: boolean;
};
export type ConsoleProjection = {
lines: ConsoleLine[];
status: string | null;
usage: string | null;
lastCursor: string | null;
};
export type WorkerTarget = {
runtime_id: string;
worker_id: string;
};
export function workerConsoleHref(target: WorkerTarget): string {
return `/runtimes/${encodeURIComponent(target.runtime_id)}/workers/${
encodeURIComponent(
target.worker_id,
)
}/console`;
}
export function workerConsolePath(runtimeId: string, workerId: string): string {
return workerConsoleHref({ runtime_id: runtimeId, worker_id: workerId });
}
export function transcriptLines(items: WorkerTranscriptItem[]): ConsoleLine[] {
return items.map((item) => ({
id: `transcript-${item.event_id}-${item.sequence}`,
kind: transcriptRoleKind(item.role),
title: `${item.role} · transcript #${item.sequence}`,
body: item.content,
detail: `event ${item.event_id}`,
source: "transcript",
}));
}
export function projectConsole(
transcript: WorkerTranscriptItem[],
events: Array<{ cursor: string; event: ProtocolEvent }> = [],
): ConsoleProjection {
return events.reduce(applyProtocolEvent, {
lines: transcriptLines(transcript),
status: null,
usage: null,
lastCursor: null,
});
}
export function applyProtocolEvent(
projection: ConsoleProjection,
envelope: { cursor: string; event: ProtocolEvent },
): ConsoleProjection {
const next: ConsoleProjection = {
lines: [...projection.lines],
status: projection.status,
usage: projection.usage,
lastCursor: envelope.cursor,
};
const event = envelope.event;
switch (event.event) {
case "user_message":
next.lines.push(
line(
envelope.cursor,
"user",
"user message",
segmentsToText(event.data.segments),
),
);
break;
case "system_item":
next.lines.push(
line(
envelope.cursor,
"system",
"system item",
jsonPreview(event.data.item),
),
);
break;
case "text_delta":
appendStreaming(
next,
envelope.cursor,
"assistant",
"assistant streaming",
event.data.text,
);
break;
case "text_done":
finalizeStreaming(
next,
"assistant",
envelope.cursor,
"assistant",
event.data.text,
);
break;
case "thinking_start":
next.lines.push(
line(envelope.cursor, "thinking", "thinking", "", undefined, true),
);
break;
case "thinking_delta":
appendStreaming(
next,
envelope.cursor,
"thinking",
"thinking",
event.data.text,
);
break;
case "thinking_done":
finalizeStreaming(
next,
"thinking",
envelope.cursor,
"thinking",
event.data.text,
);
break;
case "tool_call_start":
next.lines.push(
line(
envelope.cursor,
"tool",
`tool call · ${event.data.name}`,
`id: ${event.data.id}`,
undefined,
true,
),
);
break;
case "tool_call_args_delta":
appendToolArgs(next, envelope.cursor, event.data.id, event.data.json);
break;
case "tool_call_done":
next.lines.push(
line(
envelope.cursor,
"tool",
`tool call done · ${event.data.name}`,
event.data.arguments,
`id: ${event.data.id}`,
),
);
break;
case "tool_result":
next.lines.push(
line(
envelope.cursor,
"tool",
event.data.is_error ? "tool result error" : "tool result",
event.data.output ?? event.data.summary,
`id: ${event.data.id} · ${event.data.summary}`,
false,
event.data.is_error,
),
);
break;
case "usage":
next.usage = usageText(event.data);
next.lines.push(line(envelope.cursor, "usage", "usage", next.usage));
break;
case "error":
next.lines.push(
line(
envelope.cursor,
"error",
`error · ${event.data.code}`,
event.data.message,
undefined,
false,
true,
),
);
break;
case "snapshot":
next.status = event.data.status;
next.lines.push(
line(
envelope.cursor,
"snapshot",
`snapshot · ${event.data.status}`,
`${event.data.entries.length} entries · ${event.data.greeting.provider} / ${event.data.greeting.model}`,
`${event.data.greeting.worker_name} · context ${event.data.greeting.context_tokens}/${event.data.greeting.context_window}`,
),
);
for (const block of event.data.in_flight?.blocks ?? []) {
next.lines.push(inFlightLine(envelope.cursor, block));
}
break;
case "status":
next.status = event.data.status;
next.lines.push(
line(envelope.cursor, "status", "status", event.data.status),
);
break;
case "invoke_start":
next.lines.push(
line(envelope.cursor, "status", "invoke start", event.data.kind),
);
break;
case "turn_start":
next.lines.push(
line(
envelope.cursor,
"status",
"turn start",
`turn ${event.data.turn}`,
),
);
break;
case "turn_end":
next.lines.push(
line(
envelope.cursor,
"status",
"turn end",
`turn ${event.data.turn} · ${event.data.result}`,
),
);
break;
case "llm_call_start":
next.lines.push(
line(
envelope.cursor,
"status",
"llm call start",
`call ${event.data.llm_call}`,
),
);
break;
case "llm_call_end":
next.lines.push(
line(
envelope.cursor,
"status",
"llm call end",
`call ${event.data.llm_call}`,
),
);
break;
case "llm_retry":
next.lines.push(
line(
envelope.cursor,
"status",
"llm retry",
`${event.data.error} · attempt ${event.data.failed_attempt}/${event.data.max_attempts}`,
),
);
break;
case "llm_continuation":
next.lines.push(
line(
envelope.cursor,
"status",
"llm continuation",
`${event.data.reason} · attempt ${event.data.attempt}/${event.data.max_attempts}`,
),
);
break;
case "run_end":
next.lines.push(
line(envelope.cursor, "status", "run end", event.data.result),
);
break;
case "alert":
next.lines.push(
line(
envelope.cursor,
"status",
`alert · ${event.data.level}`,
event.data.message,
),
);
break;
case "memory_worker":
next.lines.push(
line(envelope.cursor, "status", "memory worker", event.data.message),
);
break;
case "segment_rotated":
next.lines.push(
line(
envelope.cursor,
"status",
"segment rotated",
jsonPreview(event.data.entry),
),
);
break;
case "completions":
next.lines.push(
line(
envelope.cursor,
"status",
"completions",
`${event.data.kind} · ${event.data.entries.length} entries`,
),
);
break;
case "rewind_targets":
next.lines.push(
line(
envelope.cursor,
"status",
"rewind targets",
`${event.data.targets.length} targets · head ${event.data.head_entries}`,
),
);
break;
case "rewind_applied":
next.lines.push(
line(
envelope.cursor,
"status",
"rewind applied",
`${event.data.summary.discarded_entries} discarded · ${event.data.summary.truncated_to_entries} retained`,
),
);
break;
case "workers_listed":
next.lines.push(
line(
envelope.cursor,
"status",
"workers listed",
jsonPreview(event.data.workers),
),
);
break;
case "worker_restored":
next.lines.push(
line(
envelope.cursor,
"status",
"worker restored",
jsonPreview(event.data.result),
),
);
break;
case "peer_registered":
next.lines.push(
line(
envelope.cursor,
"status",
"peer registered",
jsonPreview(event.data.result),
),
);
break;
case "compact_start":
next.lines.push(
line(envelope.cursor, "status", "compact start", "compaction started"),
);
break;
case "compact_done":
next.lines.push(
line(
envelope.cursor,
"status",
"compact done",
event.data.new_segment_id,
),
);
break;
case "compact_failed":
next.lines.push(
line(
envelope.cursor,
"error",
"compact failed",
event.data.error,
undefined,
false,
true,
),
);
break;
case "shutdown":
next.status = "shutdown";
next.lines.push(
line(envelope.cursor, "status", "shutdown", "worker shut down"),
);
break;
}
return next;
}
export function segmentsToText(segments: Segment[]): string {
return segments
.map((segment) => {
switch (segment.kind) {
case "text":
return segment.content;
case "paste":
return segment.content ||
`[paste ${segment.id}: ${segment.chars} chars / ${segment.lines} lines]`;
case "file_ref":
return `@file ${segment.path}`;
case "knowledge_ref":
return `@knowledge ${segment.slug}`;
case "workflow_invoke":
return `/${segment.slug}`;
case "unknown":
return "[unknown segment]";
}
})
.join("\n");
}
function transcriptRoleKind(role: string): ConsoleLineKind {
if (role === "user" || role === "assistant" || role === "system") {
return role;
}
return "system";
}
function line(
cursor: string,
kind: ConsoleLineKind,
title: string,
body: string,
detail?: string,
streaming = false,
error = false,
): ConsoleLine {
return {
id: `event-${cursor}-${kind}-${slugify(title)}-${body.length}`,
kind,
title,
body,
detail,
cursor,
source: "event",
streaming,
error,
};
}
function appendStreaming(
projection: ConsoleProjection,
cursor: string,
kind: "assistant" | "thinking",
title: string,
delta: string,
): void {
const existing = [...projection.lines].reverse().find((item) =>
item.kind === kind && item.streaming
);
if (existing) {
existing.body += delta;
existing.cursor = cursor;
return;
}
projection.lines.push(line(cursor, kind, title, delta, undefined, true));
}
function finalizeStreaming(
projection: ConsoleProjection,
kind: "assistant" | "thinking",
cursor: string,
title: string,
body: string,
): void {
const existing = [...projection.lines].reverse().find((item) =>
item.kind === kind && item.streaming
);
if (existing) {
existing.body = body || existing.body;
existing.streaming = false;
existing.title = title;
existing.cursor = cursor;
return;
}
projection.lines.push(line(cursor, kind, title, body));
}
function appendToolArgs(
projection: ConsoleProjection,
cursor: string,
id: string,
delta: string,
): void {
const existing = [...projection.lines]
.reverse()
.find((item) =>
item.kind === "tool" && item.streaming && item.body.includes(`id: ${id}`)
);
if (existing) {
existing.body += delta;
existing.cursor = cursor;
return;
}
projection.lines.push(
line(
cursor,
"tool",
"tool call args",
`id: ${id}\n${delta}`,
undefined,
true,
),
);
}
function usageText(
data: {
input_tokens: number | null;
output_tokens: number | null;
cache_read_input_tokens?: number | null;
},
): string {
return `input ${data.input_tokens ?? "unknown"} · output ${
data.output_tokens ?? "unknown"
} · cache ${data.cache_read_input_tokens ?? "unknown"}`;
}
function inFlightLine(cursor: string, block: InFlightBlock): ConsoleLine {
switch (block.kind) {
case "text":
return line(
cursor,
"in_flight",
"in-flight assistant text",
block.text,
undefined,
!block.finished,
);
case "thinking":
return line(
cursor,
"in_flight",
"in-flight thinking",
block.text,
undefined,
!block.finished,
);
case "tool_call":
return line(
cursor,
"in_flight",
`in-flight tool · ${block.name}`,
block.args,
`${block.id} · ${block.state ?? "pending"}`,
block.state !== "done",
);
}
}
function slugify(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(
/^-|-$/g,
"",
) || "event";
}
function jsonPreview(value: unknown): string {
try {
return JSON.stringify(value, null, 2) ?? "null";
} catch {
return String(value);
}
}
@@ -0,0 +1,91 @@
declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
readTextFile(path: string | URL): Promise<string>;
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs", async () => {
const workspacePage = await Deno.readTextFile(
new URL("../workspace-pages/WorkspacePage.svelte", import.meta.url),
);
const workersNav = await Deno.readTextFile(
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
);
const sidebar = await Deno.readTextFile(
new URL("../workspace-sidebar/WorkspaceSidebar.svelte", import.meta.url),
);
assert(
workspacePage.includes("workerConsoleHref(worker)") &&
workspacePage.includes("Open Console"),
"top Worker list should expose an attach action per Worker",
);
assert(
workersNav.includes("workerConsoleHref(worker)") &&
workersNav.includes("aria-current"),
"Workers sidebar rows should link to the Worker target Console route",
);
assert(
!sidebar.includes("CompanionNavSection") &&
sidebar.includes("WorkersNavSection"),
"standalone Companion/Console navigation should not remain canonical",
);
});
Deno.test("Worker Console page is routed by runtime_id and worker_id through backend APIs", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
const routeLoad = await Deno.readTextFile(
new URL(
"./../../routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts",
import.meta.url,
),
);
assert(
routeLoad.includes("runtimeId") && routeLoad.includes("workerId"),
"route load should expose both target ids",
);
assert(
consolePage.includes(
"/api/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}",
),
"Worker detail should use the backend Worker detail API",
);
assert(
consolePage.includes("/transcript?limit=200") &&
consolePage.includes("/events/ws") && consolePage.includes("/input"),
"Console should use bounded transcript, observation WS, and input APIs",
);
assert(
!consolePage.includes("/api/companion"),
"Console page must not use Companion-specific APIs",
);
assert(
consolePage.includes("streaming observation is not available") ||
consolePage.includes("Streaming observation is not available"),
"Console should show an explicit non-streaming degradation path",
);
assert(
consolePage.includes("function advanceReloadToken()") &&
consolePage.includes("nextReloadToken += 1") &&
!consolePage.includes("reloadToken += 1"),
"reload token advancement should not synchronously read and write the rune state",
);
assert(
consolePage.includes(
"advanceReloadToken();\n void loadConsoleData(target);",
) &&
!consolePage.includes("void refreshConsole();\n });\n\n $effect"),
"target-change effect should load data without depending on manual refresh state reads",
);
});
@@ -1,5 +1,6 @@
<script lang="ts">
import RepositoryTicketKanban from '$lib/workspace-pages/RepositoryTicketKanban.svelte';
import { workerConsoleHref } from '$lib/workspace-console/model';
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
import type {
Diagnostic,
@@ -477,6 +478,7 @@
<th>State</th>
<th>Workspace</th>
<th>Implementation</th>
<th>Attach</th>
</tr>
</thead>
<tbody>
@@ -492,6 +494,7 @@
<td>{worker.state} · {worker.status}</td>
<td>{worker.workspace.visibility} · {worker.workspace.identity}</td>
<td>{worker.implementation.kind}</td>
<td><a class="inline-link" href={workerConsoleHref(worker)}>Open Console</a></td>
</tr>
{/each}
</tbody>
@@ -1,20 +0,0 @@
<script lang="ts">
type Props = {
currentPath?: string;
};
let { currentPath = '/' }: Props = $props();
const active = $derived(currentPath === '/console');
</script>
<section class="sidebar-section" aria-labelledby="companion-console-heading">
<div class="section-heading-row">
<h2 id="companion-console-heading">Console</h2>
<span class="section-count">MVP</span>
</div>
<a class:active class="nav-item" href="/console" aria-current={active ? 'page' : undefined}>
<span class="item-title">Companion Console</span>
<span class="item-meta">status · transcript · send</span>
</a>
</section>
@@ -1,8 +1,15 @@
<script lang="ts">
import { workerConsoleHref } from '$lib/workspace-console/model';
import type { ListResponse, Worker } from './types';
const MAX_VISIBLE_WORKERS = 6;
type Props = {
currentPath?: string;
};
let { currentPath = '/' }: Props = $props();
let loading = $state(true);
let error = $state<string | null>(null);
let workers = $state<Worker[]>([]);
@@ -63,15 +70,18 @@
<p class="section-state">{placeholder ?? 'Workers will appear here when an API is connected.'}</p>
{:else}
<ul class="nav-list" aria-label="Workers">
{#each workers as worker (worker.worker_id)}
<li class="nav-item worker-nav-item">
<span class="worker-title-row">
<span class="item-title">{worker.label}</span>
<span class="worker-task-title">-</span>
</span>
<span class="item-meta">
{worker.role ? `${worker.role} · ` : ''}{worker.state} · {worker.status}
</span>
{#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
{@const href = workerConsoleHref(worker)}
<li>
<a href={href} class="nav-item worker-nav-item" class:active={currentPath === href} aria-current={currentPath === href ? 'page' : undefined}>
<span class="worker-title-row">
<span class="item-title">{worker.label}</span>
<span class="worker-task-title">-</span>
</span>
<span class="item-meta">
{worker.role ? `${worker.role} · ` : ''}{worker.state} · {worker.status} · 🖥 {worker.host_id}
</span>
</a>
</li>
{/each}
</ul>
@@ -1,5 +1,4 @@
<script lang="ts">
import CompanionNavSection from './CompanionNavSection.svelte';
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte';
@@ -42,9 +41,8 @@
</header>
<nav class="sidebar-sections" aria-label="Workspace sections">
<CompanionNavSection {currentPath} />
<RepositoriesNavSection {workspace} {currentPath} />
<ObjectivesNavSection {currentPath} />
<WorkersNavSection />
<WorkersNavSection {currentPath} />
</nav>
</aside>
@@ -1,9 +1,11 @@
export type {
import type {
Event as PodProtocolEvent,
Method as PodProtocolMethod,
Segment as PodProtocolSegment,
Segment as PodProtocolSegment
} from '$lib/generated/protocol';
export type { PodProtocolEvent, PodProtocolMethod, PodProtocolSegment };
export type ExtensionPoint = {
status: string;
note: string;
@@ -93,6 +95,53 @@ export type Worker = {
diagnostics: Diagnostic[];
};
export type WorkerOperationState = 'accepted' | 'unsupported' | 'rejected';
export type WorkerInputResult = {
state: WorkerOperationState;
runtime_id: string;
worker_id: string;
transcript_sequence?: number | null;
event_id?: number | null;
diagnostics: Diagnostic[];
};
export type WorkerTranscriptItem = {
sequence: number;
role: 'user' | 'assistant' | 'system' | string;
content: string;
event_id: number;
};
export type WorkerTranscriptProjection = {
state: WorkerOperationState;
runtime_id: string;
worker_id: string;
start: number;
limit: number;
total_items: number;
next_start?: number | null;
items: WorkerTranscriptItem[];
diagnostics: Diagnostic[];
};
export type ClientWorkerEventWsEnvelope = {
cursor: string;
event_id: string;
runtime_id: string;
worker_id: string;
payload: PodProtocolEvent;
};
export type ClientWorkerEventWsDiagnostic = {
code: string;
message: string;
};
export type ClientWorkerEventWsFrame =
| { kind: 'event'; envelope: ClientWorkerEventWsEnvelope }
| { kind: 'diagnostic'; diagnostic: ClientWorkerEventWsDiagnostic };
export type ListResponse<T> = {
workspace_id: string;
limit: number;