feat: worker attach workspace console
This commit is contained in:
@@ -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,78 @@
|
||||
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(runtimeId)}/workers/${encodeURIComponent(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",
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
@@ -585,6 +586,7 @@
|
||||
<th>State</th>
|
||||
<th>Workspace</th>
|
||||
<th>Implementation</th>
|
||||
<th>Attach</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -600,6 +602,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,12 +70,15 @@
|
||||
<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">
|
||||
<span class="item-title">{worker.label}</span>
|
||||
<span class="item-meta">
|
||||
{worker.state} · {worker.status}{worker.role ? ` · ${worker.role}` : ''}
|
||||
</span>
|
||||
{#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
|
||||
{@const href = workerConsoleHref(worker)}
|
||||
<li class="nav-item" class:active={currentPath === href}>
|
||||
<a href={href} class="nav-link" aria-current={currentPath === href ? 'page' : undefined}>
|
||||
<span class="item-title">{worker.label}</span>
|
||||
<span class="item-meta">
|
||||
{worker.state} · {worker.status}{worker.role ? ` · ${worker.role}` : ''}
|
||||
</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;
|
||||
|
||||
Reference in New Issue
Block a user