diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 642e0c31..27e6becc 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -70,6 +70,7 @@ import { startAgentRunProjectionResume } from "./server-code-agent-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 { startWorkbenchEmptySessionGc } from "./workbench-empty-session-gc.ts"; import { handleM3IoControlHttp } from "./server-m3-http.ts"; @@ -733,6 +734,11 @@ async function handleRestAdapter(request, response, url, options) { 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/")) { await handleWorkbenchReadModelHttp(request, response, url, options); 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/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/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/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"; diff --git a/internal/cloud/workbench-debug-fake-sse.test.ts b/internal/cloud/workbench-debug-fake-sse.test.ts new file mode 100644 index 00000000..04330a4a --- /dev/null +++ b/internal/cloud/workbench-debug-fake-sse.test.ts @@ -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, pattern: string): Promise { + 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) { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +} + +async function close(server: ReturnType) { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); +} + +function serverUrl(server: ReturnType): 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}`; +} diff --git a/internal/cloud/workbench-debug-fake-sse.ts b/internal/cloud/workbench-debug-fake-sse.ts new file mode 100644 index 00000000..7533a23b --- /dev/null +++ b/internal/cloud/workbench-debug-fake-sse.ts @@ -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; +} diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 550643f1..4ea06a6e 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -106,6 +106,8 @@ const runtimeConfigScript = `
加载失败:{{ workbench.error }}
发起对话,或从左侧选择 session。
diff --git a/web/hwlab-cloud-web/src/components/workbench/WorkbenchMessageCard.vue b/web/hwlab-cloud-web/src/components/workbench/WorkbenchMessageCard.vue new file mode 100644 index 00000000..b887d267 --- /dev/null +++ b/web/hwlab-cloud-web/src/components/workbench/WorkbenchMessageCard.vue @@ -0,0 +1,153 @@ + + + diff --git a/web/hwlab-cloud-web/src/router/index.ts b/web/hwlab-cloud-web/src/router/index.ts index cc581574..91072f49 100644 --- a/web/hwlab-cloud-web/src/router/index.ts +++ b/web/hwlab-cloud-web/src/router/index.ts @@ -5,6 +5,7 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router" import { installRouterGuards } from "./guards"; const CodeWorkbenchView = () => import("@/views/workbench/CodeWorkbenchView.vue"); +const WorkbenchDebugView = () => import("@/views/workbench/WorkbenchDebugView.vue"); const OpenCodeFrameView = () => import("@/views/opencode/OpenCodeFrameView.vue"); const ProjectsView = () => import("@/views/projects/ProjectsView.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: "/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: "/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", 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" } }, diff --git a/web/hwlab-cloud-web/src/router/meta.d.ts b/web/hwlab-cloud-web/src/router/meta.d.ts index c3a7cf8c..80b375a4 100644 --- a/web/hwlab-cloud-web/src/router/meta.d.ts +++ b/web/hwlab-cloud-web/src/router/meta.d.ts @@ -9,6 +9,6 @@ declare module "vue-router" { requiresAdmin?: boolean; navId?: string; title?: string; - section?: "workbench" | "opencode" | "project" | "admin" | "user" | "system"; + section?: "workbench" | "workbench-debug" | "opencode" | "project" | "admin" | "user" | "system"; } } diff --git a/web/hwlab-cloud-web/src/stores/auth.ts b/web/hwlab-cloud-web/src/stores/auth.ts index 000ee3ad..1720eabe 100644 --- a/web/hwlab-cloud-web/src/stores/auth.ts +++ b/web/hwlab-cloud-web/src/stores/auth.ts @@ -133,6 +133,7 @@ function accessFromUnknown(value: unknown): AuthAccess | undefined { function firstAllowedNavPath(allowedIds: string[]): string { const ordered = [ { id: "workbench.code", path: "/workbench" }, + { id: "workbench.debug", path: "/workbench/debug" }, { id: "opencode.root", path: "/opencode" }, { id: "project.mdtodo", path: "/projects/mdtodo" }, { id: "user.dashboard", path: "/dashboard" }, diff --git a/web/hwlab-cloud-web/src/stores/workbench-debug-fake-sse.ts b/web/hwlab-cloud-web/src/stores/workbench-debug-fake-sse.ts new file mode 100644 index 00000000..05046984 --- /dev/null +++ b/web/hwlab-cloud-web/src/stores/workbench-debug-fake-sse.ts @@ -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): 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 { + 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): 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; +} diff --git a/web/hwlab-cloud-web/src/views/workbench/WorkbenchDebugView.vue b/web/hwlab-cloud-web/src/views/workbench/WorkbenchDebugView.vue new file mode 100644 index 00000000..f3b0756f --- /dev/null +++ b/web/hwlab-cloud-web/src/views/workbench/WorkbenchDebugView.vue @@ -0,0 +1,420 @@ + + +