Merge pull request #2448 from pikasTech/fix/2447-workbench-debug-fake-sse
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success

feat: add Workbench debug fake SSE harness
This commit is contained in:
Lyon
2026-07-09 21:50:13 +08:00
committed by GitHub
14 changed files with 1589 additions and 139 deletions
+7
View File
@@ -70,6 +70,7 @@ import {
startAgentRunProjectionResume startAgentRunProjectionResume
} from "./server-code-agent-http.ts"; } from "./server-code-agent-http.ts";
import { drainWorkbenchRealtimeConnections, handleWorkbenchReadModelHttp, handleWorkbenchRealtimeHttp } from "./server-workbench-http.ts"; import { drainWorkbenchRealtimeConnections, handleWorkbenchReadModelHttp, handleWorkbenchRealtimeHttp } from "./server-workbench-http.ts";
import { handleWorkbenchDebugFakeSseHttp } from "./workbench-debug-fake-sse.ts";
import { handleWorkbenchLaunchHttp } from "./server-workbench-launch-http.ts"; import { handleWorkbenchLaunchHttp } from "./server-workbench-launch-http.ts";
import { startWorkbenchEmptySessionGc } from "./workbench-empty-session-gc.ts"; import { startWorkbenchEmptySessionGc } from "./workbench-empty-session-gc.ts";
import { handleM3IoControlHttp } from "./server-m3-http.ts"; import { handleM3IoControlHttp } from "./server-m3-http.ts";
@@ -733,6 +734,11 @@ async function handleRestAdapter(request, response, url, options) {
return; return;
} }
if (url.pathname === "/v1/workbench/debug/fake-sse" || url.pathname.startsWith("/v1/workbench/debug/fake-sse/")) {
await handleWorkbenchDebugFakeSseHttp(request, response, url, options);
return;
}
if (url.pathname === "/v1/workbench/sync" || url.pathname === "/v1/workbench/sessions" || url.pathname.startsWith("/v1/workbench/sessions/") || url.pathname.startsWith("/v1/workbench/turns/") || url.pathname.startsWith("/v1/workbench/traces/")) { if (url.pathname === "/v1/workbench/sync" || url.pathname === "/v1/workbench/sessions" || url.pathname.startsWith("/v1/workbench/sessions/") || url.pathname.startsWith("/v1/workbench/turns/") || url.pathname.startsWith("/v1/workbench/traces/")) {
await handleWorkbenchReadModelHttp(request, response, url, options); await handleWorkbenchReadModelHttp(request, response, url, options);
return; return;
@@ -964,6 +970,7 @@ function navIdForRestPath(pathname, method = "GET") {
if (pathname === "/v1/project-management" || pathname.startsWith("/v1/project-management/")) return "project.mdtodo"; if (pathname === "/v1/project-management" || pathname.startsWith("/v1/project-management/")) return "project.mdtodo";
if (pathname === "/v1/api-keys" || pathname === "/v1/api-keys/default" || pathname.startsWith("/v1/api-keys/")) return "user.apiKeys"; if (pathname === "/v1/api-keys" || pathname === "/v1/api-keys/default" || pathname.startsWith("/v1/api-keys/")) return "user.apiKeys";
if (pathname === "/v1/users/me/profile" || pathname === "/v1/users/me/password") return "system.settings"; if (pathname === "/v1/users/me/profile" || pathname === "/v1/users/me/password") return "system.settings";
if (pathname === "/v1/workbench/debug/fake-sse" || pathname.startsWith("/v1/workbench/debug/fake-sse/")) return "workbench.debug";
if (pathname === "/v1/workbench/events" || pathname === "/v1/workbench/sync" || pathname === "/v1/workbench/launches" || pathname === "/v1/workbench/sessions" || pathname.startsWith("/v1/workbench/sessions/") || pathname.startsWith("/v1/workbench/turns/") || pathname.startsWith("/v1/workbench/traces/")) return "workbench.code"; if (pathname === "/v1/workbench/events" || pathname === "/v1/workbench/sync" || pathname === "/v1/workbench/launches" || pathname === "/v1/workbench/sessions" || pathname.startsWith("/v1/workbench/sessions/") || pathname.startsWith("/v1/workbench/turns/") || pathname.startsWith("/v1/workbench/traces/")) return "workbench.code";
if (pathname === "/v1/agent/chat" || pathname === "/v1/agent/sessions" || pathname.startsWith("/v1/agent/sessions/") || pathname === "/v1/agent/chat/inspect" || pathname.startsWith("/v1/agent/chat/result/") || pathname.startsWith("/v1/agent/turns/") || pathname.startsWith("/v1/agent/traces/") || pathname === "/v1/agent/chat/cancel" || pathname === "/v1/agent/chat/steer") return "workbench.code"; if (pathname === "/v1/agent/chat" || pathname === "/v1/agent/sessions" || pathname.startsWith("/v1/agent/sessions/") || pathname === "/v1/agent/chat/inspect" || pathname.startsWith("/v1/agent/chat/result/") || pathname.startsWith("/v1/agent/turns/") || pathname.startsWith("/v1/agent/traces/") || pathname === "/v1/agent/chat/cancel" || pathname === "/v1/agent/chat/steer") return "workbench.code";
if (pathname === "/v1/admin/provider-profiles" || pathname.startsWith("/v1/admin/provider-profiles/")) return "admin.providerProfiles"; if (pathname === "/v1/admin/provider-profiles" || pathname.startsWith("/v1/admin/provider-profiles/")) return "admin.providerProfiles";
@@ -0,0 +1,131 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { test } from "bun:test";
import { handleWorkbenchDebugFakeSseHttp } from "./workbench-debug-fake-sse.ts";
test("workbench debug fake SSE API resets, steps, appends, runs, and streams typed events", async () => {
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
void handleWorkbenchDebugFakeSseHttp(request, response, url, { logger: null });
});
await listen(server);
const baseUrl = serverUrl(server);
const queueId = "api-test";
const abort = new AbortController();
try {
const initial = await jsonFetch(`${baseUrl}/v1/workbench/debug/fake-sse?queueId=${queueId}`);
assert.equal(initial.ok, true);
assert.equal(initial.queue.queueId, queueId);
assert.equal(initial.queue.cursor, 0);
assert.equal(initial.queue.remaining, 6);
const stream = await fetch(`${baseUrl}/v1/workbench/debug/fake-sse/events?queueId=${queueId}`, { signal: abort.signal });
assert.equal(stream.status, 200);
assert.match(stream.headers.get("content-type") ?? "", /text\/event-stream/u);
const reader = stream.body?.getReader();
assert.ok(reader, "SSE response must expose a readable stream");
const connected = await readUntil(reader, "workbench.connected");
assert.match(connected, /workbench\.connected/u);
const reset = await jsonFetch(`${baseUrl}/v1/workbench/debug/fake-sse/reset`, {
method: "POST",
body: JSON.stringify({ queueId, sequenceId: "trace-card-basic" })
});
assert.equal(reset.ok, true);
assert.equal(reset.queue.remaining, 6);
const next = await jsonFetch(`${baseUrl}/v1/workbench/debug/fake-sse/next`, {
method: "POST",
body: JSON.stringify({ queueId })
});
assert.equal(next.ok, true);
assert.equal(next.delivered.eventName, "workbench.message.snapshot");
assert.equal(next.delivered.deliveredTo, 1);
assert.equal(next.queue.cursor, 1);
const firstEvent = await readUntil(reader, "workbench.message.snapshot");
assert.match(firstEvent, /workbench-realtime-authority-v2/u);
assert.match(firstEvent, /msg_debug_agent/u);
const append = await jsonFetch(`${baseUrl}/v1/workbench/debug/fake-sse/append`, {
method: "POST",
body: JSON.stringify({
queueId,
events: [{
type: "trace.event",
sessionId: "ses_debug_fake_sse",
threadId: "thr_debug_fake_sse",
traceId: "trc_debug_fake_sse",
event: {
traceId: "trc_debug_fake_sse",
projectedSeq: 99,
createdAt: "2026-07-09T08:00:09.000Z",
label: "assistant:message",
type: "assistant_message",
status: "running",
message: "appended from api test"
}
}]
})
});
assert.equal(append.ok, true);
assert.equal(append.appended, 1);
assert.equal(append.queue.eventCount, 7);
assert.equal(append.queue.remaining, 6);
const run = await jsonFetch(`${baseUrl}/v1/workbench/debug/fake-sse/run`, {
method: "POST",
body: JSON.stringify({ queueId })
});
assert.equal(run.ok, true);
assert.equal(run.delivered.count, 6);
assert.equal(run.queue.remaining, 0);
const runStream = await readUntil(reader, "appended from api test");
assert.match(runStream, /workbench\.trace\.event/u);
} finally {
abort.abort();
await close(server);
}
});
async function jsonFetch(url: string, init: RequestInit = {}) {
const response = await fetch(url, {
...init,
headers: {
accept: "application/json",
...(init.body ? { "content-type": "application/json" } : {}),
...(init.headers ?? {})
}
});
const body = await response.json();
assert.equal(response.ok, true, JSON.stringify(body));
return body as any;
}
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, pattern: string): Promise<string> {
const decoder = new TextDecoder();
let text = "";
const deadline = Date.now() + 3000;
while (!text.includes(pattern)) {
if (Date.now() > deadline) throw new Error(`SSE stream did not include ${pattern}: ${text}`);
const next = await reader.read();
if (next.done) break;
text += decoder.decode(next.value, { stream: true });
}
return text;
}
async function listen(server: ReturnType<typeof createServer>) {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
}
async function close(server: ReturnType<typeof createServer>) {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
function serverUrl(server: ReturnType<typeof createServer>): string {
const address = server.address();
assert.equal(typeof address, "object");
assert.ok(address && typeof address.port === "number");
return `http://127.0.0.1:${address.port}`;
}
+438
View File
@@ -0,0 +1,438 @@
/*
* SPEC: PJ2026-010401080313 Workbench实时权威 draft-2026-07-09-p1-single-step-debug.
* Responsibility: debug-only in-memory fake Workbench SSE queues for Cloud Web single-step reducer/UI inspection.
*/
import { readBody, sendJson } from "./server-http-utils.ts";
const CONTRACT_VERSION = "workbench-debug-fake-sse-v1";
const EVENT_CONTRACT_VERSION = "workbench-events-v1";
const REALTIME_AUTHORITY = "workbench-realtime-authority-v2";
const DEFAULT_QUEUE_ID = "trace-card";
const BODY_LIMIT_BYTES = 256 * 1024;
const queues = new Map();
const clientsByQueueId = new Map();
export async function handleWorkbenchDebugFakeSseHttp(request, response, url, options = {}) {
const route = routeSuffix(url.pathname);
try {
if (route === "" || route === "/sequences") {
if (request.method !== "GET") return methodNotAllowed(response, "GET");
return sendJson(response, 200, describePayload(queueIdFromUrl(url)));
}
if (route === "/events") {
if (request.method !== "GET") return methodNotAllowed(response, "GET");
return openDebugSse(request, response, url);
}
if (route === "/reset") {
if (request.method !== "POST") return methodNotAllowed(response, "POST");
const body = await readJsonObject(request);
if (!body.ok) return sendJson(response, 400, debugError("invalid_json", body.message));
const queue = resetQueue(queueIdFromBodyOrUrl(body.value, url), String(body.value.sequenceId ?? "trace-card-basic"));
return sendJson(response, 200, { ok: true, contractVersion: CONTRACT_VERSION, queue: describeQueue(queue), sequences: describeSequences() });
}
if (route === "/append") {
if (request.method !== "POST") return methodNotAllowed(response, "POST");
const body = await readJsonObject(request);
if (!body.ok) return sendJson(response, 400, debugError("invalid_json", body.message));
const queue = ensureQueue(queueIdFromBodyOrUrl(body.value, url));
const events = appendEventsFromBody(body.value);
if (events.length === 0) return sendJson(response, 400, debugError("fake_sse_events_required", "events must contain at least one object."));
queue.events.push(...events.map((event, index) => normalizeRealtimeEvent(event, queue, queue.events.length + index + 1)));
queue.updatedAt = new Date().toISOString();
return sendJson(response, 200, { ok: true, contractVersion: CONTRACT_VERSION, appended: events.length, queue: describeQueue(queue) });
}
if (route === "/next") {
if (request.method !== "POST") return methodNotAllowed(response, "POST");
const body = await readOptionalJsonObject(request);
const queue = ensureQueue(queueIdFromBodyOrUrl(body.value ?? {}, url));
const delivered = deliverNext(queue);
return sendJson(response, 200, { ok: true, contractVersion: CONTRACT_VERSION, delivered, queue: describeQueue(queue) });
}
if (route === "/run") {
if (request.method !== "POST") return methodNotAllowed(response, "POST");
const body = await readOptionalJsonObject(request);
const queue = ensureQueue(queueIdFromBodyOrUrl(body.value ?? {}, url));
const delivered = deliverAll(queue);
return sendJson(response, 200, { ok: true, contractVersion: CONTRACT_VERSION, delivered, queue: describeQueue(queue) });
}
return sendJson(response, 404, debugError("workbench_debug_fake_sse_route_not_found", "Workbench debug fake SSE route is not implemented.", { route }));
} catch (error) {
options.logger?.warn?.({
event: "workbench_debug_fake_sse_failed",
route,
errorName: error?.name ?? "Error",
message: error instanceof Error ? error.message : String(error ?? "unknown"),
valuesRedacted: true
});
return sendJson(response, 500, debugError("workbench_debug_fake_sse_failed", "Workbench debug fake SSE request failed."));
}
}
function openDebugSse(request, response, url) {
const queue = ensureQueue(queueIdFromUrl(url));
response.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store, no-transform",
connection: "keep-alive",
"x-accel-buffering": "no",
"x-content-type-options": "nosniff"
});
if (typeof response.flushHeaders === "function") response.flushHeaders();
const clients = clientsForQueue(queue.queueId);
clients.add(response);
writeSse(response, "workbench.connected", {
type: "connected",
contractVersion: EVENT_CONTRACT_VERSION,
realtimeAuthority: REALTIME_AUTHORITY,
queue: describeQueue(queue),
serverSentAt: new Date().toISOString()
});
request.on("close", () => clients.delete(response));
}
function deliverNext(queue) {
const event = queue.events[queue.cursor] ?? null;
if (!event) return { ok: false, reason: "queue-empty", deliveredTo: 0 };
queue.cursor += 1;
queue.updatedAt = new Date().toISOString();
return deliverEvent(queue.queueId, event);
}
function deliverAll(queue) {
const results = [];
while (queue.cursor < queue.events.length) results.push(deliverNext(queue));
return { ok: true, count: results.length, deliveredTo: results.reduce((sum, item) => sum + Number(item.deliveredTo ?? 0), 0) };
}
function deliverEvent(queueId, event) {
const clients = clientsForQueue(queueId);
const eventName = eventNameFor(event);
let deliveredTo = 0;
for (const response of [...clients]) {
if (response.destroyed || response.writableEnded) {
clients.delete(response);
continue;
}
if (writeSse(response, eventName, event)) deliveredTo += 1;
}
return { ok: true, eventName, eventType: event.type ?? null, traceId: event.traceId ?? null, deliveredTo };
}
function writeSse(response, eventName, payload) {
if (response.destroyed || response.writableEnded) return false;
try {
response.write(`event: ${eventName}\n`);
const eventId = sseEventId(payload);
if (eventId) response.write(`id: ${eventId}\n`);
response.write(`data: ${JSON.stringify({ serverSentAt: new Date().toISOString(), ...payload })}\n\n`);
return true;
} catch {
return false;
}
}
function ensureQueue(queueId = DEFAULT_QUEUE_ID) {
const id = safeQueueId(queueId);
const existing = queues.get(id);
if (existing) return existing;
return resetQueue(id, "trace-card-basic");
}
function resetQueue(queueId = DEFAULT_QUEUE_ID, sequenceId = "trace-card-basic") {
const id = safeQueueId(queueId);
const sequence = builtinSequences().find((item) => item.sequenceId === sequenceId) ?? builtinSequences()[0];
const now = new Date().toISOString();
const queue = {
queueId: id,
sequenceId: sequence.sequenceId,
cursor: 0,
events: sequence.events.map((event, index) => normalizeRealtimeEvent(event, { queueId: id }, index + 1)),
createdAt: now,
updatedAt: now
};
queues.set(id, queue);
return queue;
}
function normalizeRealtimeEvent(event, queue, fallbackVersion) {
const source = recordValue(event) ?? {};
const sessionId = textValue(source.sessionId) || "ses_debug_fake_sse";
const threadId = textValue(source.threadId) || "thr_debug_fake_sse";
const traceId = textValue(source.traceId) || "trc_debug_fake_sse";
const type = textValue(source.type) || "trace.event";
const entity = recordValue(source.entity) ?? defaultEntity(type, source, fallbackVersion);
return {
sessionId,
threadId,
traceId,
...source,
contractVersion: EVENT_CONTRACT_VERSION,
realtimeAuthority: REALTIME_AUTHORITY,
sessionId,
threadId,
traceId,
type,
entity,
cursor: recordValue(source.cursor) ?? {
traceSeq: finiteNumber(source.traceSeq ?? source.event?.projectedSeq ?? fallbackVersion),
outboxSeq: finiteNumber(source.outboxSeq ?? fallbackVersion)
},
debug: {
source: "workbench-debug-fake-sse",
queueId: queue.queueId,
valuesRedacted: true
}
};
}
function defaultEntity(type, source, version) {
const traceId = textValue(source.traceId) || "trc_debug_fake_sse";
const messageId = textValue(source.message?.messageId ?? source.message?.id) || "msg_debug_agent";
const family = type === "message.snapshot" ? "messages" : type === "turn.snapshot" ? "turns" : "traceEvents";
const id = family === "messages" ? messageId : family === "turns" ? textValue(source.turn?.turnId ?? source.turn?.traceId) || traceId : `${traceId}:${version}`;
return {
family,
id,
version,
outboxSeq: version,
traceSeq: version,
projectionRevision: `debug-${version}`,
authority: REALTIME_AUTHORITY
};
}
function builtinSequences() {
const sessionId = "ses_debug_fake_sse";
const threadId = "thr_debug_fake_sse";
const traceId = "trc_debug_fake_sse";
const t0 = "2026-07-09T08:00:00.000Z";
const events = [
traceEvent({ projectedSeq: 1, createdAt: "2026-07-09T08:00:01.000Z", label: "assistant:message", type: "assistant_message", status: "running", message: "读取 Workbench 组件和 Trace 渲染上下文。", elapsedMs: 1000 }),
traceEvent({ projectedSeq: 2, createdAt: "2026-07-09T08:00:03.000Z", label: "agentrun:tool:completed", type: "tool_call", status: "completed", toolName: "commandExecution", itemId: "tool_debug_rg", command: "rg -n \"TraceTimeline|message-card\" web/hwlab-cloud-web/src", stdoutSummary: "TraceTimeline.vue\\nConversationPanel.vue\\nWorkbenchMessageCard.vue", exitCode: 0, elapsedMs: 3000 }),
traceEvent({ projectedSeq: 3, createdAt: "2026-07-09T08:00:05.000Z", label: "assistant:message", type: "assistant_message", status: "running", message: "Trace 卡片已收到 tool 事件,继续等待 terminal snapshot。", elapsedMs: 5000 }),
traceEvent({ projectedSeq: 4, createdAt: "2026-07-09T08:00:07.000Z", label: "assistant:completed", type: "assistant_message", status: "completed", message: "Fake SSE Trace 卡片渲染完成。", final: true, terminal: true, replyAuthority: true, elapsedMs: 7000 }),
traceEvent({ projectedSeq: 5, createdAt: "2026-07-09T08:00:07.500Z", label: "turn:completed", type: "completion", status: "completed", terminal: true, elapsedMs: 7500 })
];
return [
{
sequenceId: "trace-card-basic",
label: "Trace card basic",
eventCount: 6,
events: [
{
type: "message.snapshot",
sessionId,
threadId,
traceId,
message: agentMessage({ sessionId, threadId, traceId, status: "running", createdAt: t0, events: [], eventCount: 0 })
},
...events.slice(0, 3).map((event, index) => ({
type: "trace.event",
sessionId,
threadId,
traceId,
event,
snapshot: traceSnapshot({ sessionId, threadId, traceId, status: "running", events: events.slice(0, index + 1), eventCount: index + 1, startedAt: t0 })
})),
{
type: "trace.event",
sessionId,
threadId,
traceId,
event: events[3],
snapshot: traceSnapshot({ sessionId, threadId, traceId, status: "completed", events: events.slice(0, 4), eventCount: 4, startedAt: t0, finishedAt: "2026-07-09T08:00:07.000Z", durationMs: 7000, finalText: "Fake SSE Trace 卡片渲染完成。" })
},
{
type: "message.snapshot",
sessionId,
threadId,
traceId,
message: agentMessage({ sessionId, threadId, traceId, status: "completed", createdAt: t0, updatedAt: "2026-07-09T08:00:07.000Z", text: "Fake SSE Trace 卡片渲染完成。", events, eventCount: events.length, finishedAt: "2026-07-09T08:00:07.000Z", durationMs: 7000, finalText: "Fake SSE Trace 卡片渲染完成。" })
}
]
}
];
}
function traceEvent(input) {
return { traceId: "trc_debug_fake_sse", source: "debug-fake-sse", sourceSeq: input.projectedSeq, ...input };
}
function agentMessage(input) {
return {
id: "msg_debug_agent",
messageId: "msg_debug_agent",
role: "agent",
title: "Code Agent",
text: input.text ?? "",
status: input.status,
createdAt: input.createdAt,
updatedAt: input.updatedAt ?? input.createdAt,
sessionId: input.sessionId,
threadId: input.threadId,
traceId: input.traceId,
turnId: input.traceId,
timing: {
startedAt: input.createdAt,
lastEventAt: input.updatedAt ?? input.createdAt,
finishedAt: input.finishedAt ?? null,
durationMs: input.durationMs ?? null,
valuesRedacted: true
},
runnerTrace: traceSnapshot({
sessionId: input.sessionId,
threadId: input.threadId,
traceId: input.traceId,
status: input.status,
events: input.events,
eventCount: input.eventCount,
startedAt: input.createdAt,
lastEventAt: input.updatedAt ?? input.createdAt,
finishedAt: input.finishedAt ?? null,
durationMs: input.durationMs ?? null,
finalText: input.finalText
})
};
}
function traceSnapshot(input) {
return {
traceId: input.traceId,
sessionId: input.sessionId,
threadId: input.threadId,
status: input.status,
events: input.events,
eventCount: input.eventCount ?? input.events?.length ?? 0,
fullTraceLoaded: input.status === "completed",
hasMore: false,
startedAt: input.startedAt,
lastEventAt: input.lastEventAt ?? input.events?.at(-1)?.createdAt ?? input.startedAt,
finishedAt: input.finishedAt ?? null,
durationMs: input.durationMs ?? null,
timing: {
startedAt: input.startedAt,
lastEventAt: input.lastEventAt ?? input.events?.at(-1)?.createdAt ?? input.startedAt,
finishedAt: input.finishedAt ?? null,
durationMs: input.durationMs ?? null,
valuesRedacted: true
},
finalResponse: input.finalText ? { text: input.finalText, valuesRedacted: true } : undefined,
projection: {
projectionStatus: "caught-up",
projectionHealth: "caught-up",
lastProjectedSeq: input.eventCount ?? input.events?.length ?? 0,
updatedAt: input.lastEventAt ?? input.events?.at(-1)?.createdAt ?? input.startedAt,
valuesRedacted: true
}
};
}
function appendEventsFromBody(body) {
const raw = Array.isArray(body.events) ? body.events : body.event ? [body.event] : [];
return raw.filter((event) => event && typeof event === "object" && !Array.isArray(event));
}
async function readJsonObject(request) {
const body = await readBody(request, BODY_LIMIT_BYTES);
try {
const value = body ? JSON.parse(body) : {};
if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false, message: "body must be a JSON object" };
return { ok: true, value };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : "Invalid JSON body" };
}
}
async function readOptionalJsonObject(request) {
if (Number(request.headers?.["content-length"] ?? 0) <= 0) return { ok: true, value: {} };
return readJsonObject(request);
}
function describePayload(queueId) {
return { ok: true, contractVersion: CONTRACT_VERSION, sequences: describeSequences(), queue: describeQueue(ensureQueue(queueId)) };
}
function describeSequences() {
return builtinSequences().map((sequence) => ({ sequenceId: sequence.sequenceId, label: sequence.label, eventCount: sequence.events.length }));
}
function describeQueue(queue) {
return {
queueId: queue.queueId,
sequenceId: queue.sequenceId,
cursor: queue.cursor,
eventCount: queue.events.length,
remaining: Math.max(0, queue.events.length - queue.cursor),
connectedClients: clientsForQueue(queue.queueId).size,
updatedAt: queue.updatedAt
};
}
function clientsForQueue(queueId) {
const id = safeQueueId(queueId);
let clients = clientsByQueueId.get(id);
if (!clients) {
clients = new Set();
clientsByQueueId.set(id, clients);
}
return clients;
}
function queueIdFromBodyOrUrl(body, url) {
return safeQueueId(body.queueId ?? url.searchParams.get("queueId"));
}
function queueIdFromUrl(url) {
return safeQueueId(url.searchParams.get("queueId"));
}
function safeQueueId(value) {
const text = String(value ?? "").trim();
return /^[A-Za-z0-9_.:-]{3,80}$/u.test(text) ? text : DEFAULT_QUEUE_ID;
}
function eventNameFor(event) {
switch (event?.type) {
case "trace.snapshot": return "workbench.trace.snapshot";
case "trace.event": return "workbench.trace.event";
case "message.snapshot": return "workbench.message.snapshot";
case "turn.snapshot": return "workbench.turn.snapshot";
case "trace.unavailable": return "workbench.trace.unavailable";
case "error": return "workbench.error";
default: return "message";
}
}
function sseEventId(payload) {
const raw = payload?.cursor?.outboxSeq ?? payload?.outboxSeq ?? payload?.entity?.outboxSeq;
const text = textValue(raw);
if (!text || /[\r\n]/u.test(text)) return null;
return text.slice(0, 128);
}
function routeSuffix(pathname) {
return pathname.replace(/^\/v1\/workbench\/debug\/fake-sse/u, "");
}
function methodNotAllowed(response, allowed) {
return sendJson(response, 405, debugError("method_not_allowed", `Use ${allowed} for this Workbench debug route.`));
}
function debugError(code, message, extra = {}) {
return { ok: false, contractVersion: CONTRACT_VERSION, error: { code, message, layer: "workbench-debug-fake-sse", valuesRedacted: true }, ...extra };
}
function recordValue(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
}
function textValue(value) {
const text = String(value ?? "").trim();
return text || null;
}
function finiteNumber(value) {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
}
@@ -106,6 +106,8 @@ const runtimeConfigScript = `<script>\nwindow.HWLAB_CLOUD_WEB_CONFIG = {\n ...(
let state = createScenarioState("baseline"); let state = createScenarioState("baseline");
const sseClients = new Set<ServerResponse>(); const sseClients = new Set<ServerResponse>();
const debugSseClients = new Set<ServerResponse>();
let debugFakeSseQueue = createDebugFakeSseQueue();
const server = createServer((request, response) => { const server = createServer((request, response) => {
void handleRequest(request, response).catch((error) => { void handleRequest(request, response).catch((error) => {
@@ -151,6 +153,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
} }
if (path === "/auth/login" && method === "POST") return authLoginResponse(response); if (path === "/auth/login" && method === "POST") return authLoginResponse(response);
if (path === "/v1/workbench/events" && method === "GET") return sse(request, response, url); if (path === "/v1/workbench/events" && method === "GET") return sse(request, response, url);
if (path === "/v1/workbench/debug/fake-sse" || path.startsWith("/v1/workbench/debug/fake-sse/")) return debugFakeSse(request, response, url, method);
if (path === "/v1/workbench/launches" && method === "POST") return json(response, 201, await createWorkbenchLaunch(request)); if (path === "/v1/workbench/launches" && method === "POST") return json(response, 201, await createWorkbenchLaunch(request));
if (path === "/v1/workbench/sessions" && method === "GET") { if (path === "/v1/workbench/sessions" && method === "GET") {
await delay(state.sessionDelayMs); await delay(state.sessionDelayMs);
@@ -237,6 +240,10 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
if (path === "/v1/hwpod/specs") return json(response, 200, hwpodSpecsPayload()); if (path === "/v1/hwpod/specs") return json(response, 200, hwpodSpecsPayload());
if (path === "/v1/hwpod-node-ops") return json(response, 200, hwpodNodeOpsPayload()); if (path === "/v1/hwpod-node-ops") return json(response, 200, hwpodNodeOpsPayload());
if (path.startsWith("/v1/project-management")) return projectManagementPayload(request, response, url, method); if (path.startsWith("/v1/project-management")) return projectManagementPayload(request, response, url, method);
if (path === "/v1/web-performance" && method === "POST") {
const body = await readJson(request);
return json(response, 202, { accepted: true, recorded: Array.isArray(body.events) ? body.events.length : 0, valuesRedacted: true });
}
if (path === "/v1/web-performance/summary") { if (path === "/v1/web-performance/summary") {
if (state.scenarioId === "performance-summary-error") return errorDiagnosticResponse(response, 502, "/v1/web-performance/summary", "upstream_unavailable", "暂时无法连接上游。"); if (state.scenarioId === "performance-summary-error") return errorDiagnosticResponse(response, 502, "/v1/web-performance/summary", "upstream_unavailable", "暂时无法连接上游。");
const matrixStatus = performanceSummaryErrorStatus(state.scenarioId); const matrixStatus = performanceSummaryErrorStatus(state.scenarioId);
@@ -2339,6 +2346,112 @@ function sse(request: IncomingMessage, response: ServerResponse, url: URL): void
}, state.scenarioId === "progress-only-final-response" ? 5_000 : state.scenarioId === "event-replay" ? 1_500 : 350); }, state.scenarioId === "progress-only-final-response" ? 5_000 : state.scenarioId === "event-replay" ? 1_500 : 350);
} }
async function debugFakeSse(request: IncomingMessage, response: ServerResponse, url: URL, method: string): Promise<void> {
const suffix = url.pathname.replace(/^\/v1\/workbench\/debug\/fake-sse/u, "");
if (suffix === "" || suffix === "/sequences") return json(response, 200, debugFakeSsePayload());
if (suffix === "/events") {
response.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", connection: "keep-alive" });
debugSseClients.add(response);
request.on("close", () => debugSseClients.delete(response));
return writeSse(response, "workbench.connected", { type: "connected", queue: debugFakeSseQueueSummary() });
}
if (method !== "POST") return json(response, 405, { ok: false, error: { code: "method_not_allowed" } });
if (suffix === "/reset") {
debugFakeSseQueue = createDebugFakeSseQueue();
return json(response, 200, debugFakeSsePayload());
}
if (suffix === "/append") {
const body = await readJson(request);
const events = Array.isArray(body.events) ? body.events.filter((event): event is JsonRecord => Boolean(event && typeof event === "object" && !Array.isArray(event))) : [];
debugFakeSseQueue.events.push(...events.map((event, index) => debugRealtimeEvent(event, debugFakeSseQueue.events.length + index + 1)));
return json(response, 200, { ok: true, appended: events.length, queue: debugFakeSseQueueSummary() });
}
if (suffix === "/next") return json(response, 200, { ok: true, delivered: debugDeliverNext(), queue: debugFakeSseQueueSummary() });
if (suffix === "/run") {
let count = 0;
while (debugFakeSseQueue.cursor < debugFakeSseQueue.events.length) {
debugDeliverNext();
count += 1;
}
return json(response, 200, { ok: true, delivered: { ok: true, count }, queue: debugFakeSseQueueSummary() });
}
return json(response, 404, { ok: false, error: { code: "debug_fake_sse_route_not_found" } });
}
function debugDeliverNext(): JsonRecord {
const event = debugFakeSseQueue.events[debugFakeSseQueue.cursor] as JsonRecord | undefined;
if (!event) return { ok: false, reason: "queue-empty", deliveredTo: 0 };
debugFakeSseQueue.cursor += 1;
const eventName = debugEventName(event);
for (const client of Array.from(debugSseClients)) writeSse(client, eventName, event);
return { ok: true, eventName, eventType: event.type, traceId: event.traceId, deliveredTo: debugSseClients.size };
}
function debugFakeSsePayload(): JsonRecord {
return { ok: true, contractVersion: "workbench-debug-fake-sse-v1", sequences: [{ sequenceId: "trace-card-basic", label: "Trace card basic", eventCount: debugFakeSseQueue.events.length }], queue: debugFakeSseQueueSummary() };
}
function debugFakeSseQueueSummary(): JsonRecord {
return { queueId: "trace-card", sequenceId: "trace-card-basic", cursor: debugFakeSseQueue.cursor, eventCount: debugFakeSseQueue.events.length, remaining: Math.max(0, debugFakeSseQueue.events.length - debugFakeSseQueue.cursor), connectedClients: debugSseClients.size, updatedAt: new Date().toISOString() };
}
function createDebugFakeSseQueue(): { cursor: number; events: JsonRecord[] } {
const sessionId = "ses_debug_fake_sse";
const threadId = "thr_debug_fake_sse";
const traceId = "trc_debug_fake_sse";
const finalText = "Fake SSE Trace 卡片渲染完成。";
const events = [
{ traceId, projectedSeq: 1, createdAt: "2026-07-09T08:00:01.000Z", label: "assistant:message", type: "assistant_message", status: "running", message: "读取 Workbench 组件和 Trace 渲染上下文。", elapsedMs: 1000 },
{ traceId, projectedSeq: 2, createdAt: "2026-07-09T08:00:03.000Z", label: "agentrun:tool:completed", type: "tool_call", status: "completed", toolName: "commandExecution", itemId: "tool_debug_rg", command: "rg -n \"TraceTimeline|message-card\" web/hwlab-cloud-web/src", stdoutSummary: "TraceTimeline.vue\\nConversationPanel.vue\\nWorkbenchMessageCard.vue", exitCode: 0, elapsedMs: 3000 },
{ traceId, projectedSeq: 3, createdAt: "2026-07-09T08:00:05.000Z", label: "assistant:message", type: "assistant_message", status: "running", message: "Trace 卡片已收到 tool 事件,继续等待 terminal snapshot。", elapsedMs: 5000 },
{ traceId, projectedSeq: 4, createdAt: "2026-07-09T08:00:07.000Z", label: "assistant:completed", type: "assistant_message", status: "completed", message: finalText, final: true, terminal: true, replyAuthority: true, elapsedMs: 7000 },
{ traceId, projectedSeq: 5, createdAt: "2026-07-09T08:00:07.500Z", label: "turn:completed", type: "completion", status: "completed", terminal: true, elapsedMs: 7500 }
];
return {
cursor: 0,
events: [
debugRealtimeEvent({ type: "message.snapshot", sessionId, threadId, traceId, message: debugAgentMessage(sessionId, threadId, traceId, "running", []) }, 1),
...events.slice(0, 3).map((event, index) => debugRealtimeEvent({ type: "trace.event", sessionId, threadId, traceId, event, snapshot: debugTraceSnapshot(sessionId, threadId, traceId, "running", events.slice(0, index + 1)) }, index + 2)),
debugRealtimeEvent({ type: "trace.event", sessionId, threadId, traceId, event: events[3], snapshot: debugTraceSnapshot(sessionId, threadId, traceId, "completed", events.slice(0, 4)) }, 5),
debugRealtimeEvent({ type: "message.snapshot", sessionId, threadId, traceId, message: debugAgentMessage(sessionId, threadId, traceId, "completed", events) }, 6)
]
};
}
function debugRealtimeEvent(event: JsonRecord, version: number): JsonRecord {
const type = String(event.type ?? "trace.event");
const traceId = String(event.traceId ?? "trc_debug_fake_sse");
const message = event.message && typeof event.message === "object" ? event.message as JsonRecord : null;
return {
contractVersion: "workbench-events-v1",
realtimeAuthority: "workbench-realtime-authority-v2",
sessionId: event.sessionId ?? "ses_debug_fake_sse",
threadId: event.threadId ?? "thr_debug_fake_sse",
traceId,
...event,
type,
entity: event.entity ?? { family: type === "message.snapshot" ? "messages" : "traceEvents", id: type === "message.snapshot" ? message?.messageId ?? "msg_debug_agent" : `${traceId}:${version}`, version, projectionRevision: `debug-${version}`, authority: "workbench-realtime-authority-v2" },
cursor: event.cursor ?? { traceSeq: version, outboxSeq: version }
};
}
function debugAgentMessage(sessionId: string, threadId: string, traceId: string, status: string, events: JsonRecord[]): JsonRecord {
const finalText = "Fake SSE Trace 卡片渲染完成。";
return { id: "msg_debug_agent", messageId: "msg_debug_agent", role: "agent", title: "Code Agent", text: status === "completed" ? finalText : "", status, createdAt: "2026-07-09T08:00:00.000Z", updatedAt: "2026-07-09T08:00:07.000Z", sessionId, threadId, traceId, turnId: traceId, runnerTrace: debugTraceSnapshot(sessionId, threadId, traceId, status, events) };
}
function debugTraceSnapshot(sessionId: string, threadId: string, traceId: string, status: string, events: JsonRecord[]): JsonRecord {
const finalText = "Fake SSE Trace 卡片渲染完成。";
return { traceId, sessionId, threadId, status, events, eventCount: events.length, fullTraceLoaded: status === "completed", hasMore: false, startedAt: "2026-07-09T08:00:00.000Z", lastEventAt: events.at(-1)?.createdAt ?? "2026-07-09T08:00:00.000Z", finishedAt: status === "completed" ? "2026-07-09T08:00:07.000Z" : null, durationMs: status === "completed" ? 7000 : null, finalResponse: status === "completed" ? { text: finalText, status: "completed" } : undefined };
}
function debugEventName(event: JsonRecord): string {
if (event.type === "message.snapshot") return "workbench.message.snapshot";
if (event.type === "trace.snapshot") return "workbench.trace.snapshot";
if (event.type === "turn.snapshot") return "workbench.turn.snapshot";
return event.type === "error" ? "workbench.error" : "workbench.trace.event";
}
function broadcastSse(eventName: string, payload: JsonRecord): void { function broadcastSse(eventName: string, payload: JsonRecord): void {
for (const client of Array.from(sseClients)) writeSse(client, eventName, payload); for (const client of Array.from(sseClients)) writeSse(client, eventName, payload);
} }
@@ -2426,6 +2539,7 @@ function recordLedger(method: string, url: URL): void {
function isLegacyWorkbenchPath(path: string): boolean { function isLegacyWorkbenchPath(path: string): boolean {
if (/^\/v1\/workbench\/(sessions(?:\/[^/]+(?:\/messages)?)?|events|turns\/[^/]+|traces\/[^/]+\/events)$/u.test(path)) return false; if (/^\/v1\/workbench\/(sessions(?:\/[^/]+(?:\/messages)?)?|events|turns\/[^/]+|traces\/[^/]+\/events)$/u.test(path)) return false;
if (path === "/v1/workbench/debug/fake-sse" || path.startsWith("/v1/workbench/debug/fake-sse/")) return false;
if (path === "/v1/agent/chat" || /^\/v1\/agent\/sessions(?:\/[^/]+(?:\/select)?)?$/u.test(path)) return false; if (path === "/v1/agent/chat" || /^\/v1\/agent\/sessions(?:\/[^/]+(?:\/select)?)?$/u.test(path)) return false;
return path.startsWith("/v1/workbench/") || path.startsWith("/v1/agent/"); return path.startsWith("/v1/workbench/") || path.startsWith("/v1/agent/");
} }
@@ -2441,7 +2555,9 @@ function authPayload(): JsonRecord {
function authLoginResponse(response: ServerResponse): void { function authLoginResponse(response: ServerResponse): void {
if (state.scenarioId === "auth-upstream-unavailable") return json(response, 502, { ok: false, status: "failed", error: { code: "upstream_unavailable", message: "auth upstream unavailable", retryable: true, layer: "proxy" } }); if (state.scenarioId === "auth-upstream-unavailable") return json(response, 502, { ok: false, status: "failed", error: { code: "upstream_unavailable", message: "auth upstream unavailable", retryable: true, layer: "proxy" } });
return json(response, 401, { ok: false, status: "failed", error: { code: "invalid_credentials", message: "invalid credentials" } }); if (state.scenarioId === "auth-invalid-credentials") return json(response, 401, { ok: false, status: "failed", error: { code: "invalid_credentials", message: "invalid credentials" } });
response.setHeader("Set-Cookie", "hwlab_session=authenticated; Path=/; HttpOnly; SameSite=Lax");
return json(response, 200, authPayload());
} }
function performanceSummaryErrorStatus(scenarioId: string): number | null { function performanceSummaryErrorStatus(scenarioId: string): number | null {
+1
View File
@@ -4,6 +4,7 @@
export { fetchJson, fetchText, type ActivityRef, type ActivityRefSource, type ApiRequestOptions } from "./client"; export { fetchJson, fetchText, type ActivityRef, type ActivityRefSource, type ApiRequestOptions } from "./client";
export { authAPI, HWLAB_WEB_SESSION_COOKIE } from "./auth"; export { authAPI, HWLAB_WEB_SESSION_COOKIE } from "./auth";
export { workbenchAPI } from "./workbench"; export { workbenchAPI } from "./workbench";
export { workbenchDebugAPI, connectWorkbenchDebugFakeSse, type WorkbenchDebugFakeSseQueue, type WorkbenchDebugFakeSseResponse, type WorkbenchDebugFakeSseSequence, type WorkbenchDebugFakeSseStream } from "./workbench-debug";
export { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent } from "./workbench-events"; export { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent } from "./workbench-events";
export { agentAPI } from "./agent"; export { agentAPI } from "./agent";
export { hwpodAPI } from "./hwpod"; export { hwpodAPI } from "./hwpod";
@@ -0,0 +1,113 @@
// SPEC: PJ2026-010401080313 Workbench实时权威 draft-2026-07-09-p1-single-step-debug.
// Responsibility: Cloud Web client for Workbench debug-only fake SSE queues.
import { fetchJson, type ApiRequestOptions } from "@/api/client";
import type { ApiResult } from "@/types";
import type { WorkbenchRealtimeEvent } from "./workbench-events";
export interface WorkbenchDebugFakeSseSequence {
sequenceId: string;
label: string;
eventCount: number;
}
export interface WorkbenchDebugFakeSseQueue {
queueId: string;
sequenceId: string;
cursor: number;
eventCount: number;
remaining: number;
connectedClients: number;
updatedAt?: string | null;
}
export interface WorkbenchDebugFakeSseResponse {
ok?: boolean;
contractVersion?: string;
sequences?: WorkbenchDebugFakeSseSequence[];
queue?: WorkbenchDebugFakeSseQueue;
appended?: number;
delivered?: {
ok?: boolean;
count?: number;
reason?: string;
eventName?: string;
eventType?: string | null;
traceId?: string | null;
deliveredTo?: number;
};
error?: { code?: string; message?: string; [key: string]: unknown };
}
export interface WorkbenchDebugFakeSseStreamOptions {
queueId: string;
onEvent: (event: WorkbenchRealtimeEvent, eventName: string) => void;
onOpen?: () => void;
onError?: (event: Event) => void;
}
export interface WorkbenchDebugFakeSseStream {
close: () => void;
}
const DEBUG_FAKE_SSE_EVENTS = [
"workbench.connected",
"workbench.trace.snapshot",
"workbench.trace.event",
"workbench.trace.unavailable",
"workbench.message.snapshot",
"workbench.turn.snapshot",
"workbench.error"
];
export const workbenchDebugAPI = {
describe: (queueId = "trace-card", options: ApiRequestOptions = {}): Promise<ApiResult<WorkbenchDebugFakeSseResponse>> => fetchJson(debugPath("", { queueId }), { ...options, timeoutName: "workbench debug fake sse" }),
reset: (input: { queueId: string; sequenceId: string }, options: ApiRequestOptions = {}): Promise<ApiResult<WorkbenchDebugFakeSseResponse>> => fetchJson(debugPath("/reset"), { ...options, method: "POST", body: JSON.stringify(input), timeoutName: "workbench debug fake sse reset" }),
append: (input: { queueId: string; events: WorkbenchRealtimeEvent[] }, options: ApiRequestOptions = {}): Promise<ApiResult<WorkbenchDebugFakeSseResponse>> => fetchJson(debugPath("/append"), { ...options, method: "POST", body: JSON.stringify(input), timeoutName: "workbench debug fake sse append" }),
next: (input: { queueId: string }, options: ApiRequestOptions = {}): Promise<ApiResult<WorkbenchDebugFakeSseResponse>> => fetchJson(debugPath("/next"), { ...options, method: "POST", body: JSON.stringify(input), timeoutName: "workbench debug fake sse next" }),
run: (input: { queueId: string }, options: ApiRequestOptions = {}): Promise<ApiResult<WorkbenchDebugFakeSseResponse>> => fetchJson(debugPath("/run"), { ...options, method: "POST", body: JSON.stringify(input), timeoutName: "workbench debug fake sse run" })
};
export function connectWorkbenchDebugFakeSse(options: WorkbenchDebugFakeSseStreamOptions): WorkbenchDebugFakeSseStream | null {
if (typeof EventSource === "undefined") return null;
const source = new EventSource(debugPath("/events", { queueId: options.queueId }), { withCredentials: true });
source.onopen = () => options.onOpen?.();
source.onerror = (event) => options.onError?.(event);
const listeners = DEBUG_FAKE_SSE_EVENTS.map((name) => {
const listener = (event: MessageEvent) => {
const payload = parseRealtimeEvent(event.data);
if (payload) options.onEvent(payload, name);
};
source.addEventListener(name, listener);
return { name, listener };
});
source.onmessage = (event) => {
const payload = parseRealtimeEvent(event.data);
if (payload) options.onEvent(payload, "message");
};
return {
close() {
for (const { name, listener } of listeners) source.removeEventListener(name, listener);
source.close();
}
};
}
function debugPath(suffix: string, params: Record<string, string | null | undefined> = {}): string {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
const text = typeof value === "string" ? value.trim() : "";
if (text) query.set(key, text);
}
const qs = query.toString();
return `/v1/workbench/debug/fake-sse${suffix}${qs ? `?${qs}` : ""}`;
}
function parseRealtimeEvent(raw: string): WorkbenchRealtimeEvent | null {
try {
const value = JSON.parse(raw);
return value && typeof value === "object" ? value as WorkbenchRealtimeEvent : null;
} catch {
return null;
}
}
@@ -32,7 +32,7 @@ interface NavSection {
} }
const navSections = [ const navSections = [
{ title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench", icon: "C", navId: "workbench.code" }, { name: "OpenCode", label: "OpenCode", path: "/opencode", icon: "O", navId: "opencode.root" }, { name: "Dashboard", label: "概览", path: "/dashboard", icon: "D", navId: "user.dashboard" }] }, { title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench", icon: "C", navId: "workbench.code" }, { name: "WorkbenchDebug", label: "调试", path: "/workbench/debug", icon: "T", navId: "workbench.debug" }, { name: "OpenCode", label: "OpenCode", path: "/opencode", icon: "O", navId: "opencode.root" }, { name: "Dashboard", label: "概览", path: "/dashboard", icon: "D", navId: "user.dashboard" }] },
{ title: "项目", items: [{ name: "Projects", label: "项目", path: "/projects", icon: "P", navId: "project.overview" }, { name: "ProjectMdtodo", label: "MDTODO", path: "/projects/mdtodo", icon: "M", navId: "project.mdtodo" }] }, { title: "项目", items: [{ name: "Projects", label: "项目", path: "/projects", icon: "P", navId: "project.overview" }, { name: "ProjectMdtodo", label: "MDTODO", path: "/projects/mdtodo", icon: "M", navId: "project.mdtodo" }] },
{ title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys", icon: "K", navId: "user.apiKeys" }, { name: "Usage", label: "用量", path: "/usage", icon: "U", navId: "user.usage" }, { name: "Billing", label: "充值", path: "/billing", icon: "$", navId: "user.billing" }] }, { title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys", icon: "K", navId: "user.apiKeys" }, { name: "Usage", label: "用量", path: "/usage", icon: "U", navId: "user.usage" }, { name: "Billing", label: "充值", path: "/billing", icon: "$", navId: "user.billing" }] },
{ title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access", icon: "A", navId: "admin.access" }, { name: "AdminSecrets", label: "密钥", path: "/admin/secrets", icon: "K", navId: "admin.secrets" }, { name: "Users", label: "用户", path: "/admin/users", icon: "P", navId: "admin.users" }, { name: "AdminBilling", label: "账务", path: "/admin/billing", icon: "B", navId: "admin.billing" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups", icon: "H", navId: "admin.hwpodGroups" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles", icon: "R", navId: "admin.providerProfiles" }] }, { title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access", icon: "A", navId: "admin.access" }, { name: "AdminSecrets", label: "密钥", path: "/admin/secrets", icon: "K", navId: "admin.secrets" }, { name: "Users", label: "用户", path: "/admin/users", icon: "P", navId: "admin.users" }, { name: "AdminBilling", label: "账务", path: "/admin/billing", icon: "B", navId: "admin.billing" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups", icon: "H", navId: "admin.hwpodGroups" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles", icon: "R", navId: "admin.providerProfiles" }] },
@@ -6,22 +6,17 @@ import { computed, nextTick, onMounted, ref, watch } from "vue";
import type { ChatMessage } from "@/types"; import type { ChatMessage } from "@/types";
import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue"; import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue";
import LoadingState from "@/components/common/LoadingState.vue"; import LoadingState from "@/components/common/LoadingState.vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import TraceTimeline from "@/components/agent/TraceTimeline.vue";
import CodeAgentStatusSummary from "@/components/workbench/CodeAgentStatusSummary.vue"; import CodeAgentStatusSummary from "@/components/workbench/CodeAgentStatusSummary.vue";
import MessageActions from "@/components/workbench/MessageActions.vue"; import MessageActions from "@/components/workbench/MessageActions.vue";
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
import MessageTraceDebugPanel from "@/components/workbench/MessageTraceDebugPanel.vue"; import MessageTraceDebugPanel from "@/components/workbench/MessageTraceDebugPanel.vue";
import { workbenchTraceTimelinePolicy } from "@/config/runtime"; import WorkbenchMessageCard from "@/components/workbench/WorkbenchMessageCard.vue";
import { useWorkbenchBottomFollowScroll } from "@/composables/useWorkbenchScrollRuntime"; import { useWorkbenchBottomFollowScroll } from "@/composables/useWorkbenchScrollRuntime";
import { useClipboard } from "@/composables/useClipboard"; import { useClipboard } from "@/composables/useClipboard";
import { useWorkbenchNowTicker } from "@/composables/useWorkbenchNowTicker";
import { useWorkbenchStore } from "@/stores/workbench"; import { useWorkbenchStore } from "@/stores/workbench";
import { buildWorkbenchTimelineRows, workbenchTimelineSignature } from "@/stores/workbench-timeline-model"; import { buildWorkbenchTimelineRows, workbenchTimelineSignature } from "@/stores/workbench-timeline-model";
import { messageDiagnosticView } from "@/utils/workbench-error-runtime"; import { messageDiagnosticView } from "@/utils/workbench-error-runtime";
import { acknowledgeWorkbenchVisibleAfterPaint } from "@/utils/workbench-performance"; import { acknowledgeWorkbenchVisibleAfterPaint } from "@/utils/workbench-performance";
import { traceIdentityText } from "./message-rendering"; import { traceIdentityText } from "./message-rendering";
import { traceLifecycleExpanded } from "./trace-lifecycle";
const workbench = useWorkbenchStore(); const workbench = useWorkbenchStore();
const clipboard = useClipboard(); const clipboard = useClipboard();
@@ -31,10 +26,7 @@ const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBotto
const detailMessageId = ref<string | null>(null); const detailMessageId = ref<string | null>(null);
const visibleMessages = computed(() => workbench.sessionDetailLoading ? [] : workbench.activeMessages); const visibleMessages = computed(() => workbench.sessionDetailLoading ? [] : workbench.activeMessages);
const visibleTimelineRows = computed(() => buildWorkbenchTimelineRows(visibleMessages.value)); const visibleTimelineRows = computed(() => buildWorkbenchTimelineRows(visibleMessages.value));
const hasRunningAgentMessages = computed(() => visibleMessages.value.some(isRunningMessage));
const { nowMs } = useWorkbenchNowTicker(hasRunningAgentMessages);
const detailMessage = computed(() => workbench.activeMessages.find((message) => message.id === detailMessageId.value) ?? null); const detailMessage = computed(() => workbench.activeMessages.find((message) => message.id === detailMessageId.value) ?? null);
const traceTimelinePolicy = computed(() => workbenchTraceTimelinePolicy());
const scrollSignature = computed(() => workbenchTimelineSignature(visibleTimelineRows.value)); const scrollSignature = computed(() => workbenchTimelineSignature(visibleTimelineRows.value));
watch(scrollSignature, async () => { watch(scrollSignature, async () => {
@@ -57,33 +49,6 @@ function openDetails(message: ChatMessage): void {
detailMessageId.value = message.id; detailMessageId.value = message.id;
} }
function isRunningMessage(message: ChatMessage): boolean {
return message.role === "agent" && ["pending", "running"].includes(String(message.status ?? "").toLowerCase());
}
function isTerminalAgentMessage(message: ChatMessage): boolean {
if (message.role !== "agent") return false;
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(message.status ?? "").trim().toLowerCase().replace(/_/gu, "-"));
}
function visibleMessageText(message: ChatMessage): string {
const text = String(message.text ?? "").trim();
if (message.role === "agent" && !isTerminalAgentMessage(message)) return "";
return text;
}
function showStatusBadge(message: ChatMessage): boolean {
return message.role !== "agent" || !isRunningMessage(message);
}
function isAwaitingAgentBody(message: ChatMessage): boolean {
return isRunningMessage(message) && !message.runnerTrace && !visibleMessageText(message);
}
function showMessageText(message: ChatMessage): boolean {
return Boolean(visibleMessageText(message));
}
function messageHasDiagnostic(message: ChatMessage): boolean { function messageHasDiagnostic(message: ChatMessage): boolean {
return diagnosticFor(message).visible; return diagnosticFor(message).visible;
} }
@@ -95,79 +60,6 @@ function diagnosticFor(message: ChatMessage) {
function acknowledgeVisibleMessages(): void { function acknowledgeVisibleMessages(): void {
acknowledgeWorkbenchVisibleAfterPaint({ messages: workbench.activeMessages, activeSessionId: workbench.activeSessionId, detailLoading: workbench.sessionDetailLoading }); acknowledgeWorkbenchVisibleAfterPaint({ messages: workbench.activeMessages, activeSessionId: workbench.activeSessionId, detailLoading: workbench.sessionDetailLoading });
} }
function traceStorageKey(message: ChatMessage): string {
return `hwlab.workbench.trace-open.${message.traceId ?? message.runnerTrace?.traceId ?? message.id}`;
}
function traceAutoExpanded(message: ChatMessage): boolean | null {
return traceLifecycleExpanded(message, traceTimelinePolicy.value);
}
function traceForDisplay(message: ChatMessage): ChatMessage["runnerTrace"] {
return message.runnerTrace;
}
function messageDurationMeta(message: ChatMessage): { text: string; label: string } | null {
if (message.role !== "agent") return null;
const timing = messageTimingForDisplay(message);
const durationMs = isRunningMessage(message) ? durationSince(timing?.startedAt) : terminalMessageDurationMs(timing);
if (durationMs === null) return null;
const value = formatDuration(durationMs);
return { text: `耗时 ${value}`, label: `总耗时:${value}` };
}
function messageActivityMeta(message: ChatMessage): { text: string; label: string } | null {
if (!isRunningMessage(message)) return null;
const timing = messageTimingForDisplay(message);
const ageMs = durationSince(timing?.lastEventAt);
if (ageMs === null) return null;
const value = formatDuration(ageMs) + "前";
return { text: `最近 ${value}`, label: `最近事件:${value}` };
}
function messageTimingForDisplay(message: ChatMessage): ChatMessage["timing"] {
// The main message card consumes only the durable message timing projection.
// Trace timing belongs to TraceTimeline detail/history reads; letting those
// reads drive the card timer makes running turns jump when a later trace
// snapshot carries an older startedAt/lastEventAt window.
return message.timing ?? null;
}
function terminalMessageDurationMs(timing: ChatMessage["timing"]): number | null {
const durationMs = finiteDurationMs(timing?.durationMs);
if (durationMs === 0 && timing?.finishedAt) return 1000;
return durationMs;
}
function durationSince(timestamp: unknown): number | null {
const startedAtMs = timestampMs(timestamp);
return startedAtMs === null ? null : Math.max(0, nowMs.value - startedAtMs);
}
function timestampMs(value: unknown): number | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Date.parse(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
function finiteDurationMs(value: unknown): number | null {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
}
function formatDuration(ms: number): string {
const seconds = ms > 0 && ms < 1000 ? 1 : Math.max(0, Math.floor(ms / 1000));
if (seconds < 60) return `${seconds}`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}${seconds % 60}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时 ${minutes % 60}`;
const days = Math.floor(hours / 24);
return `${days}${hours % 24} 小时`;
}
</script> </script>
<template> <template>
@@ -180,33 +72,7 @@ function formatDuration(ms: number): string {
</div> </div>
<div v-else-if="row.type === 'Retry'" class="timeline-row timeline-retry" :data-session-id="row.sessionId || undefined" :data-trace-id="row.traceId || undefined">等待重试</div> <div v-else-if="row.type === 'Retry'" class="timeline-row timeline-retry" :data-session-id="row.sessionId || undefined" :data-trace-id="row.traceId || undefined">等待重试</div>
<div v-else-if="row.type === 'Error'" class="timeline-row timeline-error" :data-session-id="row.sessionId || undefined" :data-trace-id="row.traceId || undefined">{{ row.text }}</div> <div v-else-if="row.type === 'Error'" class="timeline-row timeline-error" :data-session-id="row.sessionId || undefined" :data-trace-id="row.traceId || undefined">{{ row.text }}</div>
<article <WorkbenchMessageCard v-else-if="row.message" :message="row.message" @details="openDetails" />
v-else-if="row.message"
class="message-card"
:data-role="row.message.role"
:data-status="row.message.status"
:data-session-id="row.message.sessionId || undefined"
:data-message-id="row.message.id || undefined"
:data-trace-id="row.message.traceId || row.message.runnerTrace?.traceId || undefined"
:data-turn-id="row.message.turnId || row.message.traceId || row.message.runnerTrace?.traceId || undefined"
>
<header v-if="row.message.role !== 'user'">
<div class="message-header-main">
<strong>{{ row.message.title }}</strong>
<span v-if="messageDurationMeta(row.message)" class="message-timing-meta message-duration-meta" :aria-label="messageDurationMeta(row.message)?.label" :title="messageDurationMeta(row.message)?.label">{{ messageDurationMeta(row.message)?.text }}</span>
</div>
<div class="message-header-actions">
<span v-if="messageActivityMeta(row.message)" class="message-activity-meta" :aria-label="messageActivityMeta(row.message)?.label" :title="messageActivityMeta(row.message)?.label">{{ messageActivityMeta(row.message)?.text }}</span>
<span v-if="isRunningMessage(row.message)" class="agent-running-indicator" aria-label="运行中" role="status"><span aria-hidden="true" /></span>
<StatusBadge v-if="showStatusBadge(row.message)" :status="row.message.status" />
<button v-if="row.message.role === 'agent'" class="message-detail-button" type="button" aria-label="运行详情" title="运行详情" @click="openDetails(row.message)">!</button>
</div>
</header>
<TraceTimeline v-if="row.message.role === 'agent' && row.message.runnerTrace" :trace="traceForDisplay(row.message)" :auto-expanded="traceAutoExpanded(row.message)" :storage-key="traceStorageKey(row.message)" />
<LoadingState v-if="isAwaitingAgentBody(row.message)" class="message-loading" label="思考中..." compact />
<MessageMarkdown v-if="showMessageText(row.message)" class="message-text" :source="visibleMessageText(row.message)" />
<ApiErrorDiagnostic v-if="messageHasDiagnostic(row.message)" class="message-diagnostic projection-diagnostic" :error="diagnosticFor(row.message).text" :api-error="diagnosticFor(row.message).apiError" :diagnostic="diagnosticFor(row.message).diagnostic" compact />
</article>
</template> </template>
<div v-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0 && workbench.error" class="conversation-empty-hint conversation-error-hint">加载失败{{ workbench.error }}</div> <div v-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0 && workbench.error" class="conversation-empty-hint conversation-error-hint">加载失败{{ workbench.error }}</div>
<div v-else-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0" class="conversation-empty-hint">发起对话或从左侧选择 session</div> <div v-else-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0" class="conversation-empty-hint">发起对话或从左侧选择 session</div>
@@ -0,0 +1,153 @@
<script setup lang="ts">
// SPEC: PJ2026-010401080313 Workbench draft-2026-07-09-p1-single-step-debug; PJ2026-0106050514 Workbench draft-2026-06-30-p0-1297-spec-first.
// Responsibility: shared Workbench message card and TraceTimeline rendering used by production Workbench and debug harnesses.
import { computed } from "vue";
import type { ChatMessage } from "@/types";
import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue";
import LoadingState from "@/components/common/LoadingState.vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import TraceTimeline from "@/components/agent/TraceTimeline.vue";
import { useWorkbenchNowTicker } from "@/composables/useWorkbenchNowTicker";
import { workbenchTraceTimelinePolicy } from "@/config/runtime";
import { messageDiagnosticView } from "@/utils/workbench-error-runtime";
import MessageMarkdown from "./MessageMarkdown.vue";
import { traceLifecycleExpanded } from "./trace-lifecycle";
const props = withDefaults(defineProps<{
message: ChatMessage;
detailEnabled?: boolean;
traceAutoExpanded?: boolean | null;
storageKeyPrefix?: string;
}>(), {
detailEnabled: true,
traceAutoExpanded: null,
storageKeyPrefix: "hwlab.workbench.trace-open"
});
const emit = defineEmits<{
details: [message: ChatMessage];
}>();
const running = computed(() => isRunningMessage(props.message));
const { nowMs } = useWorkbenchNowTicker(running);
const traceTimelinePolicy = computed(() => workbenchTraceTimelinePolicy());
const diagnostic = computed(() => messageDiagnosticView(props.message));
const visibleText = computed(() => visibleMessageText(props.message));
const durationMeta = computed(() => messageDurationMeta(props.message));
const activityMeta = computed(() => messageActivityMeta(props.message));
const autoExpanded = computed(() => props.traceAutoExpanded ?? traceLifecycleExpanded(props.message, traceTimelinePolicy.value));
function isRunningMessage(message: ChatMessage): boolean {
return message.role === "agent" && ["pending", "running"].includes(String(message.status ?? "").toLowerCase());
}
function isTerminalAgentMessage(message: ChatMessage): boolean {
if (message.role !== "agent") return false;
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(message.status ?? "").trim().toLowerCase().replace(/_/gu, "-"));
}
function visibleMessageText(message: ChatMessage): string {
const text = String(message.text ?? "").trim();
if (message.role === "agent" && !isTerminalAgentMessage(message)) return "";
return text;
}
function showStatusBadge(message: ChatMessage): boolean {
return message.role !== "agent" || !isRunningMessage(message);
}
function isAwaitingAgentBody(message: ChatMessage): boolean {
return isRunningMessage(message) && !message.runnerTrace && !visibleMessageText(message);
}
function messageDurationMeta(message: ChatMessage): { text: string; label: string } | null {
if (message.role !== "agent") return null;
const timing = messageTimingForDisplay(message);
const durationMs = isRunningMessage(message) ? durationSince(timing?.startedAt) : terminalMessageDurationMs(timing);
if (durationMs === null) return null;
const value = formatDuration(durationMs);
return { text: `耗时 ${value}`, label: `总耗时:${value}` };
}
function messageActivityMeta(message: ChatMessage): { text: string; label: string } | null {
if (!isRunningMessage(message)) return null;
const timing = messageTimingForDisplay(message);
const ageMs = durationSince(timing?.lastEventAt);
if (ageMs === null) return null;
const value = formatDuration(ageMs) + "前";
return { text: `最近 ${value}`, label: `最近事件:${value}` };
}
function messageTimingForDisplay(message: ChatMessage): ChatMessage["timing"] {
return message.timing ?? null;
}
function terminalMessageDurationMs(timing: ChatMessage["timing"]): number | null {
const durationMs = finiteDurationMs(timing?.durationMs);
if (durationMs === 0 && timing?.finishedAt) return 1000;
return durationMs;
}
function durationSince(timestamp: unknown): number | null {
const startedAtMs = timestampMs(timestamp);
return startedAtMs === null ? null : Math.max(0, nowMs.value - startedAtMs);
}
function timestampMs(value: unknown): number | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Date.parse(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
function finiteDurationMs(value: unknown): number | null {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
}
function formatDuration(ms: number): string {
const seconds = ms > 0 && ms < 1000 ? 1 : Math.max(0, Math.floor(ms / 1000));
if (seconds < 60) return `${seconds}`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}${seconds % 60}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时 ${minutes % 60}`;
const days = Math.floor(hours / 24);
return `${days}${hours % 24} 小时`;
}
function traceStorageKey(message: ChatMessage): string {
return `${props.storageKeyPrefix}.${message.traceId ?? message.runnerTrace?.traceId ?? message.id}`;
}
</script>
<template>
<article
class="message-card"
:data-role="message.role"
:data-status="message.status"
:data-session-id="message.sessionId || undefined"
:data-message-id="message.id || undefined"
:data-trace-id="message.traceId || message.runnerTrace?.traceId || undefined"
:data-turn-id="message.turnId || message.traceId || message.runnerTrace?.traceId || undefined"
>
<header v-if="message.role !== 'user'">
<div class="message-header-main">
<strong>{{ message.title }}</strong>
<span v-if="durationMeta" class="message-timing-meta message-duration-meta" :aria-label="durationMeta.label" :title="durationMeta.label">{{ durationMeta.text }}</span>
</div>
<div class="message-header-actions">
<span v-if="activityMeta" class="message-activity-meta" :aria-label="activityMeta.label" :title="activityMeta.label">{{ activityMeta.text }}</span>
<span v-if="running" class="agent-running-indicator" aria-label="运行中" role="status"><span aria-hidden="true" /></span>
<StatusBadge v-if="showStatusBadge(message)" :status="message.status" />
<button v-if="detailEnabled && message.role === 'agent'" class="message-detail-button" type="button" aria-label="运行详情" title="运行详情" @click="emit('details', message)">!</button>
</div>
</header>
<TraceTimeline v-if="message.role === 'agent' && message.runnerTrace" :trace="message.runnerTrace" :auto-expanded="autoExpanded" :storage-key="traceStorageKey(message)" />
<LoadingState v-if="isAwaitingAgentBody(message)" class="message-loading" label="思考中..." compact />
<MessageMarkdown v-if="visibleText" class="message-text" :source="visibleText" />
<ApiErrorDiagnostic v-if="diagnostic.visible" class="message-diagnostic projection-diagnostic" :error="diagnostic.text" :api-error="diagnostic.apiError" :diagnostic="diagnostic.diagnostic" compact />
</article>
</template>
+2
View File
@@ -5,6 +5,7 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router"
import { installRouterGuards } from "./guards"; import { installRouterGuards } from "./guards";
const CodeWorkbenchView = () => import("@/views/workbench/CodeWorkbenchView.vue"); const CodeWorkbenchView = () => import("@/views/workbench/CodeWorkbenchView.vue");
const WorkbenchDebugView = () => import("@/views/workbench/WorkbenchDebugView.vue");
const OpenCodeFrameView = () => import("@/views/opencode/OpenCodeFrameView.vue"); const OpenCodeFrameView = () => import("@/views/opencode/OpenCodeFrameView.vue");
const ProjectsView = () => import("@/views/projects/ProjectsView.vue"); const ProjectsView = () => import("@/views/projects/ProjectsView.vue");
const ProjectMdtodoView = () => import("@/views/projects/MdtodoView.vue"); const ProjectMdtodoView = () => import("@/views/projects/MdtodoView.vue");
@@ -14,6 +15,7 @@ const routes: RouteRecordRaw[] = [
{ path: "/login", name: "Login", component: () => import("@/views/LoginView.vue"), meta: { requiresAuth: false, title: "登录" } }, { path: "/login", name: "Login", component: () => import("@/views/LoginView.vue"), meta: { requiresAuth: false, title: "登录" } },
{ path: "/register", name: "Register", component: () => import("@/views/RegisterView.vue"), meta: { requiresAuth: false, title: "注册" } }, { path: "/register", name: "Register", component: () => import("@/views/RegisterView.vue"), meta: { requiresAuth: false, title: "注册" } },
{ path: "/dashboard", name: "Dashboard", component: () => import("@/views/user/DashboardView.vue"), meta: { requiresAuth: true, navId: "user.dashboard", title: "平台概览", section: "user" } }, { path: "/dashboard", name: "Dashboard", component: () => import("@/views/user/DashboardView.vue"), meta: { requiresAuth: true, navId: "user.dashboard", title: "平台概览", section: "user" } },
{ path: "/workbench/debug", name: "WorkbenchDebug", component: WorkbenchDebugView, meta: { requiresAuth: true, navId: "workbench.debug", title: "Workbench 调试", section: "workbench-debug" } },
{ path: "/workbench/sessions/:sessionId", alias: ["/workspace/sessions/:sessionId"], name: "CodeWorkbenchSession", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } }, { path: "/workbench/sessions/:sessionId", alias: ["/workspace/sessions/:sessionId"], name: "CodeWorkbenchSession", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } },
{ path: "/workbench", alias: ["/workspace"], name: "CodeWorkbench", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } }, { path: "/workbench", alias: ["/workspace"], name: "CodeWorkbench", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } },
{ path: "/opencode", name: "OpenCode", component: OpenCodeFrameView, meta: { requiresAuth: true, navId: "opencode.root", title: "OpenCode", section: "opencode" } }, { path: "/opencode", name: "OpenCode", component: OpenCodeFrameView, meta: { requiresAuth: true, navId: "opencode.root", title: "OpenCode", section: "opencode" } },
+1 -1
View File
@@ -9,6 +9,6 @@ declare module "vue-router" {
requiresAdmin?: boolean; requiresAdmin?: boolean;
navId?: string; navId?: string;
title?: string; title?: string;
section?: "workbench" | "opencode" | "project" | "admin" | "user" | "system"; section?: "workbench" | "workbench-debug" | "opencode" | "project" | "admin" | "user" | "system";
} }
} }
+1
View File
@@ -133,6 +133,7 @@ function accessFromUnknown(value: unknown): AuthAccess | undefined {
function firstAllowedNavPath(allowedIds: string[]): string { function firstAllowedNavPath(allowedIds: string[]): string {
const ordered = [ const ordered = [
{ id: "workbench.code", path: "/workbench" }, { id: "workbench.code", path: "/workbench" },
{ id: "workbench.debug", path: "/workbench/debug" },
{ id: "opencode.root", path: "/opencode" }, { id: "opencode.root", path: "/opencode" },
{ id: "project.mdtodo", path: "/projects/mdtodo" }, { id: "project.mdtodo", path: "/projects/mdtodo" },
{ id: "user.dashboard", path: "/dashboard" }, { id: "user.dashboard", path: "/dashboard" },
@@ -0,0 +1,202 @@
// SPEC: PJ2026-010401080313 Workbench实时权威 draft-2026-07-09-p1-single-step-debug.
// Responsibility: isolated fake-SSE reducer harness for Workbench debug pages.
import type { WorkbenchRealtimeEvent } from "@/api/workbench-events";
import { mergeRunnerTrace, snapshotToRunnerTrace, type TraceSnapshot } from "@/composables/workbench-trace-snapshot";
import type { ChatMessage, TraceEvent } from "@/types";
import { firstNonEmptyString } from "@/utils";
import { reduceWorkbenchRealtimeEvent } from "./workbench-event-reducer";
import { planWorkbenchRealtimeApply, type WorkbenchRealtimeApplyStep } from "./workbench-realtime-plan";
export interface WorkbenchDebugFakeSseLogEntry {
id: string;
eventName: string;
eventType: string;
actionType: string;
stepTypes: string[];
traceId: string | null;
accepted: boolean;
reason: string | null;
observedAt: string;
}
export interface WorkbenchDebugFakeSseState {
message: ChatMessage | null;
logs: WorkbenchDebugFakeSseLogEntry[];
error: string | null;
}
export function createWorkbenchDebugFakeSseState(): WorkbenchDebugFakeSseState {
return { message: null, logs: [], error: null };
}
export function applyWorkbenchDebugFakeSseEvent(state: WorkbenchDebugFakeSseState, event: WorkbenchRealtimeEvent, eventName: string): WorkbenchDebugFakeSseState {
const reduced = reduceWorkbenchRealtimeEvent(event, eventName);
const plan = planWorkbenchRealtimeApply(reduced.action);
let next: WorkbenchDebugFakeSseState = {
...state,
logs: [debugLogEntry(event, eventName, reduced.action.type, plan.steps, reduced.action.type === "ignore" ? reduced.action.reason : null), ...state.logs].slice(0, 60)
};
for (const step of plan.steps) next = applyStep(next, step);
if (reduced.action.type === "ignore") next = { ...next, error: reduced.action.reason };
return next;
}
function applyStep(state: WorkbenchDebugFakeSseState, step: WorkbenchRealtimeApplyStep): WorkbenchDebugFakeSseState {
switch (step.type) {
case "apply-message-snapshot":
return step.realtimeEvent.message ? { ...state, message: normalizeDebugMessage(step.realtimeEvent.message), error: null } : state;
case "apply-trace-event":
return applyTraceEvent(state, step.traceId, step.event, step.snapshot, step.realtimeEvent);
case "apply-trace-snapshot":
return applyTraceSnapshot(state, step.traceId, step.snapshot);
case "apply-turn-snapshot":
return applyTurnSnapshot(state, step.turn);
case "apply-projection-error":
return { ...state, error: firstNonEmptyString(step.realtimeEvent.error?.message, step.realtimeEvent.reason, "projection error") ?? "projection error" };
case "clear-active-trace":
return state.message?.runnerTrace?.traceId === step.traceId ? { ...state, message: { ...state.message, runnerTrace: null } } : state;
}
}
function applyTraceEvent(state: WorkbenchDebugFakeSseState, traceId: string | null, event: WorkbenchRealtimeEvent["event"], snapshot: WorkbenchRealtimeEvent["snapshot"], realtimeEvent: WorkbenchRealtimeEvent): WorkbenchDebugFakeSseState {
const id = firstNonEmptyString(traceId, event?.traceId, snapshot?.traceId, realtimeEvent.traceId);
if (!id) return state;
const message = state.message ?? debugMessageForTrace(id, realtimeEvent);
const trace = traceFromRealtime(id, snapshot, event ? [event] : [], message.runnerTrace ?? null);
return {
...state,
error: null,
message: {
...message,
status: trace.status === "completed" ? "completed" : message.status,
runnerTrace: mergeRunnerTrace(message.runnerTrace, trace),
updatedAt: new Date().toISOString()
}
};
}
function applyTraceSnapshot(state: WorkbenchDebugFakeSseState, traceId: string | null, snapshot: WorkbenchRealtimeEvent["snapshot"]): WorkbenchDebugFakeSseState {
const id = firstNonEmptyString(traceId, snapshot?.traceId);
if (!id || !snapshot) return state;
const message = state.message ?? debugMessageForTrace(id, { sessionId: snapshot.sessionId, threadId: snapshot.threadId, traceId: id });
const trace = traceFromRealtime(id, snapshot, Array.isArray(snapshot.events) ? snapshot.events : [], message.runnerTrace ?? null);
return {
...state,
error: null,
message: {
...message,
status: trace.status === "completed" ? "completed" : message.status,
runnerTrace: mergeRunnerTrace(message.runnerTrace, trace),
updatedAt: new Date().toISOString()
}
};
}
function applyTurnSnapshot(state: WorkbenchDebugFakeSseState, turn: NonNullable<WorkbenchRealtimeEvent["turn"]>): WorkbenchDebugFakeSseState {
const traceId = firstNonEmptyString(turn.traceId);
if (!traceId || !state.message || firstNonEmptyString(state.message.traceId, state.message.runnerTrace?.traceId) !== traceId) return state;
const status = normalizeStatus(turn.status);
return status ? { ...state, message: { ...state.message, status, updatedAt: new Date().toISOString() } } : state;
}
function traceFromRealtime(traceId: string, snapshot: WorkbenchRealtimeEvent["snapshot"], events: TraceEvent[], existing: ChatMessage["runnerTrace"]): NonNullable<ChatMessage["runnerTrace"]> {
const snapshotEvents = Array.isArray(snapshot?.events) ? snapshot.events : null;
const previousEvents = Array.isArray(existing?.events) ? existing.events : [];
const nextEvents = snapshotEvents ?? mergeDebugTraceEvents(previousEvents, events);
const source = (snapshot ?? {}) as TraceSnapshot;
return snapshotToRunnerTrace({
...source,
traceId,
status: firstNonEmptyString(source.status, events.at(-1)?.status, existing?.status) ?? "running",
sessionId: source.sessionId ?? existing?.sessionId ?? null,
threadId: source.threadId ?? existing?.threadId ?? null,
events: nextEvents,
eventCount: finiteNumber(source.eventCount) ?? nextEvents.length,
fullTraceLoaded: source.fullTraceLoaded === true,
hasMore: source.hasMore === true,
eventSource: "debug-fake-sse"
} as TraceSnapshot);
}
function mergeDebugTraceEvents(previous: TraceEvent[], incoming: TraceEvent[]): TraceEvent[] {
if (incoming.length === 0) return previous;
const keys = new Set(previous.map(traceEventKey).filter((item): item is string => Boolean(item)));
const merged = [...previous];
for (const event of incoming) {
const key = traceEventKey(event);
if (key && keys.has(key)) continue;
if (key) keys.add(key);
merged.push(event);
}
return merged.sort((left, right) => (finiteNumber(left.projectedSeq) ?? 0) - (finiteNumber(right.projectedSeq) ?? 0));
}
function traceEventKey(event: TraceEvent): string | null {
return firstNonEmptyString(event.sourceEventId, event.id, event.itemId, event.projectedSeq != null ? `${event.traceId ?? "trace"}:${event.projectedSeq}` : null);
}
function debugMessageForTrace(traceId: string, event: Pick<WorkbenchRealtimeEvent, "sessionId" | "threadId" | "traceId">): ChatMessage {
const now = new Date().toISOString();
return {
id: `msg_debug_${traceId}`,
messageId: `msg_debug_${traceId}`,
role: "agent",
title: "Code Agent",
text: "",
status: "running",
createdAt: now,
updatedAt: now,
traceId,
turnId: traceId,
sessionId: firstNonEmptyString(event.sessionId) ?? "ses_debug_fake_sse",
threadId: firstNonEmptyString(event.threadId) ?? "thr_debug_fake_sse",
runnerTrace: null,
timing: { startedAt: now, lastEventAt: now, finishedAt: null, durationMs: null, valuesRedacted: true }
};
}
function normalizeDebugMessage(message: ChatMessage): ChatMessage {
const role = (String(message.role ?? "") === "assistant" ? "agent" : message.role) as ChatMessage["role"];
return {
...message,
role,
id: firstNonEmptyString(message.messageId, message.id) ?? "msg_debug_agent",
messageId: firstNonEmptyString(message.messageId, message.id) ?? "msg_debug_agent",
title: firstNonEmptyString(message.title) ?? (role === "user" ? "用户" : "Code Agent"),
status: normalizeStatus(message.status) ?? "running",
createdAt: firstNonEmptyString(message.createdAt) ?? new Date().toISOString(),
text: role === "agent" && !isTerminalStatus(message.status) ? "" : String(message.text ?? "")
};
}
function debugLogEntry(event: WorkbenchRealtimeEvent, eventName: string, actionType: string, steps: WorkbenchRealtimeApplyStep[], reason: string | null): WorkbenchDebugFakeSseLogEntry {
const ignored = actionType === "ignore";
return {
id: `${Date.now()}:${eventName}:${event.traceId ?? "none"}:${Math.random().toString(16).slice(2)}`,
eventName,
eventType: firstNonEmptyString(event.type) ?? "unknown",
actionType,
stepTypes: steps.map((step) => step.type),
traceId: firstNonEmptyString(event.traceId, event.snapshot?.traceId, event.event?.traceId, event.message?.traceId) ?? null,
accepted: !ignored,
reason: ignored ? reason ?? "ignored" : null,
observedAt: new Date().toISOString()
};
}
function normalizeStatus(value: unknown): ChatMessage["status"] | null {
const status = String(value ?? "").trim().toLowerCase().replace(/_/gu, "-");
if (["pending", "sent", "running", "completed", "failed", "blocked", "timeout", "canceled"].includes(status)) return status as ChatMessage["status"];
if (status === "cancelled") return "canceled";
return null;
}
function isTerminalStatus(value: unknown): boolean {
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled"].includes(String(value ?? "").trim().toLowerCase().replace(/_/gu, "-"));
}
function finiteNumber(value: unknown): number | null {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
}
@@ -0,0 +1,420 @@
<script setup lang="ts">
// SPEC: PJ2026-010401080313 Workbench draft-2026-07-09-p1-single-step-debug.
// Responsibility: Workbench debug route for backend-driven fake SSE single-step Trace card inspection.
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { connectWorkbenchDebugFakeSse, workbenchDebugAPI, type WorkbenchDebugFakeSseQueue, type WorkbenchDebugFakeSseSequence, type WorkbenchDebugFakeSseStream } from "@/api";
import StatusBadge from "@/components/common/StatusBadge.vue";
import WorkbenchMessageCard from "@/components/workbench/WorkbenchMessageCard.vue";
import type { WorkbenchRealtimeEvent } from "@/api/workbench-events";
import { applyWorkbenchDebugFakeSseEvent, createWorkbenchDebugFakeSseState } from "@/stores/workbench-debug-fake-sse";
const queueId = "trace-card";
const selectedSequenceId = ref("trace-card-basic");
const sequences = ref<WorkbenchDebugFakeSseSequence[]>([]);
const queue = ref<WorkbenchDebugFakeSseQueue | null>(null);
const projection = ref(createWorkbenchDebugFakeSseState());
const streamStatus = ref<"connecting" | "open" | "error" | "closed">("connecting");
const busy = ref(false);
const error = ref<string | null>(null);
const appendDraft = ref(defaultAppendDraft());
let stream: WorkbenchDebugFakeSseStream | null = null;
const message = computed(() => projection.value.message);
const logs = computed(() => projection.value.logs);
const queueSummary = computed(() => queue.value ? `${queue.value.cursor}/${queue.value.eventCount}` : "-");
const streamBadgeStatus = computed(() => streamStatus.value === "open" ? "completed" : streamStatus.value === "error" ? "failed" : "running");
const canStep = computed(() => !busy.value && (queue.value?.remaining ?? 0) > 0);
onMounted(async () => {
await refreshDescription();
openStream();
await resetQueue();
});
onBeforeUnmount(() => {
stream?.close();
stream = null;
streamStatus.value = "closed";
});
async function refreshDescription(): Promise<void> {
const response = await workbenchDebugAPI.describe(queueId);
if (!response.ok) {
error.value = response.error || "Workbench debug fake SSE 后端不可用";
return;
}
sequences.value = response.data?.sequences ?? [];
queue.value = response.data?.queue ?? null;
selectedSequenceId.value = queue.value?.sequenceId ?? sequences.value[0]?.sequenceId ?? "trace-card-basic";
}
function openStream(): void {
stream?.close();
streamStatus.value = "connecting";
stream = connectWorkbenchDebugFakeSse({
queueId,
onOpen: () => { streamStatus.value = "open"; },
onError: () => { streamStatus.value = "error"; },
onEvent: (event, eventName) => {
if (eventName === "workbench.connected") return;
projection.value = applyWorkbenchDebugFakeSseEvent(projection.value, event, eventName);
error.value = projection.value.error;
}
});
if (!stream) streamStatus.value = "error";
}
async function resetQueue(): Promise<void> {
busy.value = true;
error.value = null;
projection.value = createWorkbenchDebugFakeSseState();
try {
const response = await workbenchDebugAPI.reset({ queueId, sequenceId: selectedSequenceId.value });
if (!response.ok) throw new Error(response.error || "reset failed");
queue.value = response.data?.queue ?? queue.value;
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
async function stepNext(): Promise<void> {
await controlQueue(() => workbenchDebugAPI.next({ queueId }));
}
async function runAll(): Promise<void> {
await controlQueue(() => workbenchDebugAPI.run({ queueId }));
}
async function appendDraftEvents(): Promise<void> {
error.value = null;
const parsed = parseAppendDraft(appendDraft.value);
if (!parsed.ok) {
error.value = parsed.error;
return;
}
await controlQueue(() => workbenchDebugAPI.append({ queueId, events: parsed.events }));
}
async function controlQueue(operation: () => ReturnType<typeof workbenchDebugAPI.next>): Promise<void> {
busy.value = true;
error.value = null;
try {
const response = await operation();
if (!response.ok) throw new Error(response.error || "queue operation failed");
queue.value = response.data?.queue ?? queue.value;
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
function parseAppendDraft(raw: string): { ok: true; events: WorkbenchRealtimeEvent[] } | { ok: false; error: string } {
try {
const value = JSON.parse(raw);
const events = Array.isArray(value) ? value : Array.isArray(value?.events) ? value.events : [value];
const normalized = events.filter((event: unknown): event is WorkbenchRealtimeEvent => Boolean(event && typeof event === "object" && !Array.isArray(event)));
return normalized.length > 0 ? { ok: true, events: normalized } : { ok: false, error: "JSON 中没有可追加的 event 对象" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : "JSON 解析失败" };
}
}
function defaultAppendDraft(): string {
return JSON.stringify({
type: "trace.event",
traceId: "trc_debug_fake_sse",
sessionId: "ses_debug_fake_sse",
threadId: "thr_debug_fake_sse",
event: {
traceId: "trc_debug_fake_sse",
projectedSeq: 99,
createdAt: "2026-07-09T08:00:09.000Z",
label: "assistant:message",
type: "assistant_message",
status: "running",
message: "这是前端临时追加到后端队列的 fake SSE 事件。",
elapsedMs: 9000
}
}, null, 2);
}
</script>
<template>
<section class="workbench-debug-route">
<header class="workbench-debug-header">
<div>
<p class="page-eyebrow">Workbench Debug</p>
<h1>实时调试台</h1>
</div>
<div class="workbench-debug-status">
<StatusBadge :status="streamBadgeStatus" :label="`SSE ${streamStatus}`" />
<span class="debug-counter">Queue {{ queueSummary }}</span>
</div>
</header>
<nav class="workbench-debug-tabs" aria-label="Workbench 调试子标签">
<button class="debug-tab" data-active="true" type="button">SSE Trace</button>
</nav>
<section class="workbench-debug-toolbar" aria-label="fake SSE 控制">
<label>
<span>序列</span>
<select v-model="selectedSequenceId" class="input" :disabled="busy" @change="resetQueue">
<option v-for="sequence in sequences" :key="sequence.sequenceId" :value="sequence.sequenceId">{{ sequence.label }} · {{ sequence.eventCount }}</option>
</select>
</label>
<button class="btn btn-secondary" type="button" :disabled="busy" @click="resetQueue">重置</button>
<button class="btn btn-primary" type="button" :disabled="!canStep" @click="stepNext">单步发送</button>
<button class="btn btn-secondary" type="button" :disabled="!canStep" @click="runAll">全速运行</button>
</section>
<p v-if="error" class="workbench-debug-error">{{ error }}</p>
<div class="workbench-debug-grid">
<section class="debug-surface" aria-label="Trace 卡片投影">
<WorkbenchMessageCard v-if="message" :message="message" :detail-enabled="false" :trace-auto-expanded="true" storage-key-prefix="hwlab.workbench.debug.trace-open" />
<div v-else class="debug-empty">等待第一条 fake SSE message.snapshot</div>
</section>
<aside class="debug-side">
<section class="debug-append" aria-label="追加 fake SSE">
<div class="debug-section-title">
<strong>Append</strong>
<button class="btn btn-sm btn-secondary" type="button" :disabled="busy" @click="appendDraftEvents">追加到后端队列</button>
</div>
<textarea v-model="appendDraft" class="debug-json-input" spellcheck="false" />
</section>
<section class="debug-log" aria-label="SSE 决策日志">
<div class="debug-section-title">
<strong>Reducer</strong>
<span>{{ logs.length }} events</span>
</div>
<ol>
<li v-for="entry in logs" :key="entry.id" :data-accepted="entry.accepted ? 'true' : 'false'">
<code>{{ entry.eventType }}</code>
<span>{{ entry.actionType }}</span>
<small>{{ entry.stepTypes.join(", ") || entry.reason || "no-op" }}</small>
</li>
</ol>
</section>
</aside>
</div>
</section>
</template>
<style scoped>
.workbench-debug-route {
display: grid;
min-height: 100%;
grid-template-rows: auto auto auto auto minmax(0, 1fr);
gap: 12px;
color: #111827;
}
.workbench-debug-header,
.workbench-debug-toolbar,
.debug-section-title,
.workbench-debug-status {
display: flex;
align-items: center;
}
.workbench-debug-header {
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid #d8e1eb;
padding: 4px 2px 12px;
}
.workbench-debug-header h1 {
margin: 0;
font-size: 24px;
line-height: 1.1;
}
.workbench-debug-status {
gap: 10px;
}
.debug-counter {
border: 1px solid #d1d5db;
border-radius: 8px;
background: white;
padding: 7px 10px;
color: #475569;
font-size: 12px;
font-weight: 750;
}
.workbench-debug-tabs {
display: flex;
gap: 8px;
border-bottom: 1px solid #d8e1eb;
}
.debug-tab {
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
padding: 10px 12px;
color: #475569;
font-size: 13px;
font-weight: 800;
}
.debug-tab[data-active="true"] {
border-bottom-color: #0e7490;
color: #0f172a;
}
.workbench-debug-toolbar {
flex-wrap: wrap;
gap: 10px;
}
.workbench-debug-toolbar label {
display: grid;
width: min(360px, 100%);
gap: 4px;
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.workbench-debug-grid {
display: grid;
min-height: 0;
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
gap: 12px;
}
.debug-surface,
.debug-side,
.debug-append,
.debug-log {
min-width: 0;
min-height: 0;
}
.debug-surface {
overflow: auto;
border: 1px solid #d8e1eb;
border-radius: 8px;
background: #f8fafc;
padding: 12px;
}
.debug-empty,
.workbench-debug-error {
border-radius: 8px;
padding: 12px;
}
.debug-empty {
border: 1px dashed #cbd5e1;
background: white;
color: #64748b;
font-size: 13px;
}
.workbench-debug-error {
border: 1px solid #fecaca;
background: #fff1f2;
color: #9f1239;
font-size: 13px;
}
.debug-side {
display: grid;
grid-template-rows: minmax(240px, 1fr) minmax(160px, 0.8fr);
gap: 12px;
}
.debug-append,
.debug-log {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 8px;
border: 1px solid #d8e1eb;
border-radius: 8px;
background: white;
padding: 10px;
}
.debug-section-title {
justify-content: space-between;
gap: 10px;
color: #334155;
}
.debug-section-title span {
color: #64748b;
font-size: 12px;
}
.debug-json-input {
min-height: 0;
resize: none;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #0f172a;
padding: 10px;
color: #d1fae5;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.45;
}
.debug-log ol {
display: grid;
align-content: start;
gap: 6px;
min-height: 0;
margin: 0;
overflow: auto;
padding: 0;
list-style: none;
}
.debug-log li {
display: grid;
grid-template-columns: minmax(96px, 0.7fr) minmax(96px, 0.7fr) minmax(0, 1fr);
gap: 8px;
border-left: 3px solid #22c55e;
background: #f8fafc;
padding: 8px;
color: #334155;
font-size: 12px;
}
.debug-log li[data-accepted="false"] {
border-left-color: #ef4444;
}
.debug-log code {
color: #0f766e;
white-space: nowrap;
}
.debug-log small {
overflow: hidden;
color: #64748b;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 980px) {
.workbench-debug-grid {
grid-template-columns: minmax(0, 1fr);
}
.debug-side {
grid-template-rows: 280px 220px;
}
}
</style>