/* * SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0 * 职责: Workbench REST read model。只读投影 session/message/turn/trace facts,不执行 AgentRun sync、billing finalize 或 workspace repair。 */ import { createHash } from "node:crypto"; import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; import { parsePositiveInteger, safeConversationId, safeOpaqueId, safeSessionId, safeTraceId, sendJson } from "./server-http-utils.ts"; import { createWorkbenchReadModel } from "./workbench-read-model.ts"; import { createWorkbenchTurnProjection, RUNNING_STATUSES, TERMINAL_STATUSES } from "./workbench-turn-projection.ts"; const DEFAULT_PAGE_LIMIT = 50; const MAX_PAGE_LIMIT = 100; const DEFAULT_WORKBENCH_SSE_HEARTBEAT_MS = 15000; export async function handleWorkbenchReadModelHttp(request, response, url, options = {}) { const perf = options.backendPerformance; const auth = perf ? await perf.measure("workbench_auth", () => authenticateWorkbenchRead(request, response, options)) : await authenticateWorkbenchRead(request, response, options); if (!auth) return; if (url.pathname === "/v1/workbench/sessions") { if (request.method !== "GET") return methodNotAllowed(response, "GET"); await (perf ? perf.measure("workbench_session_list", () => handleWorkbenchSessionList(response, url, options, auth.actor)) : handleWorkbenchSessionList(response, url, options, auth.actor)); return; } const sessionMessagesMatch = url.pathname.match(/^\/v1\/workbench\/sessions\/([^/]+)\/messages$/u); if (sessionMessagesMatch) { if (request.method !== "GET") return methodNotAllowed(response, "GET"); await (perf ? perf.measure("workbench_message_page", () => handleWorkbenchMessagePage(response, url, options, auth.actor, decodeURIComponent(sessionMessagesMatch[1]))) : handleWorkbenchMessagePage(response, url, options, auth.actor, decodeURIComponent(sessionMessagesMatch[1]))); return; } const sessionMatch = url.pathname.match(/^\/v1\/workbench\/sessions\/([^/]+)$/u); if (sessionMatch) { if (request.method !== "GET") return methodNotAllowed(response, "GET"); await (perf ? perf.measure("workbench_session_detail", () => handleWorkbenchSessionDetail(response, options, auth.actor, decodeURIComponent(sessionMatch[1]))) : handleWorkbenchSessionDetail(response, options, auth.actor, decodeURIComponent(sessionMatch[1]))); return; } const turnMatch = url.pathname.match(/^\/v1\/workbench\/turns\/([^/]+)$/u); if (turnMatch) { if (request.method !== "GET") return methodNotAllowed(response, "GET"); await (perf ? perf.measure("workbench_turn_snapshot", () => handleWorkbenchTurnSnapshot(response, url, options, auth.actor, decodeURIComponent(turnMatch[1]))) : handleWorkbenchTurnSnapshot(response, url, options, auth.actor, decodeURIComponent(turnMatch[1]))); return; } const traceEventsMatch = url.pathname.match(/^\/v1\/workbench\/traces\/([^/]+)\/events$/u); if (traceEventsMatch) { if (request.method !== "GET") return methodNotAllowed(response, "GET"); await (perf ? perf.measure("workbench_trace_events", () => handleWorkbenchTraceEventPage(response, url, options, auth.actor, decodeURIComponent(traceEventsMatch[1]))) : handleWorkbenchTraceEventPage(response, url, options, auth.actor, decodeURIComponent(traceEventsMatch[1]))); return; } sendJson(response, 404, workbenchError("workbench_route_not_found", "Workbench read model route is not implemented.", { route: url.pathname })); } export async function handleWorkbenchRealtimeHttp(request, response, url, options = {}) { if (request.method !== "GET") return methodNotAllowed(response, "GET"); const perf = options.backendPerformance; const auth = perf ? await perf.measure("workbench_auth", () => authenticateWorkbenchRead(request, response, options)) : await authenticateWorkbenchRead(request, response, options); if (!auth) return; if (url.searchParams.has("projectId") || url.searchParams.has("workspaceId")) { sendJson(response, 400, workbenchError("workbench_authority_removed", "Workbench read model is keyed by sessionId/threadId/traceId only.")); return; } const requestedSessionId = safeSessionId(url.searchParams.get("sessionId") ?? url.searchParams.get("includeSessionId")); const requestedTraceId = safeTraceId(url.searchParams.get("traceId")); const heartbeatMs = parsePositiveInteger(options.env?.HWLAB_WORKBENCH_SSE_HEARTBEAT_MS, DEFAULT_WORKBENCH_SSE_HEARTBEAT_MS); const traceStore = options.traceStore ?? defaultCodeAgentTraceStore; const readModel = createWorkbenchReadModel(options, auth.actor); let closed = false; const cleanup = []; 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" }); const writeEvent = (name, payload = {}) => { if (closed || response.destroyed) return; const startedAt = nowMs(); const eventCreatedAt = realtimeEventCreatedAt(payload); const traceSeq = realtimeTraceSeq(payload); response.write(`event: ${name}\n`); response.write(`data: ${JSON.stringify({ contractVersion: "workbench-events-v1", serverSentAt: new Date().toISOString(), eventCreatedAt, traceSeq, ...payload })}\n\n`); perf?.recordPhase({ phase: "sse_write", durationMs: nowMs() - startedAt, outcome: "ok" }); }; writeEvent("workbench.connected", { type: "connected", status: "connected", heartbeatMs, filters: { sessionId: requestedSessionId, traceId: requestedTraceId } }); const initialSession = requestedSessionId ? await readModel.getSessionById(requestedSessionId) : null; const initialSessionSnapshot = objectValue(initialSession?.session); const streamSessionId = safeSessionId(initialSession?.id ?? requestedSessionId) ?? null; const streamThreadId = safeOpaqueId(initialSession?.threadId ?? initialSessionSnapshot.threadId) ?? (textValue(initialSession?.threadId ?? initialSessionSnapshot.threadId) || null); const activeTraceId = requestedTraceId ?? safeTraceId(initialSession?.lastTraceId ?? initialSessionSnapshot.lastTraceId ?? initialSessionSnapshot.currentTraceId) ?? null; if (activeTraceId) { await (perf ? perf.measure("workbench_initial_trace", () => writeTraceRealtimeSnapshot({ writeEvent, options, actor: auth.actor, traceId: activeTraceId, reason: "initial" })) : writeTraceRealtimeSnapshot({ writeEvent, options, actor: auth.actor, traceId: activeTraceId, reason: "initial" })); const unsubscribe = traceStore.subscribe(activeTraceId, (event, snapshot) => { if (event) { writeEvent("workbench.trace.event", { type: "trace.event", sessionId: streamSessionId, threadId: streamThreadId, traceId: activeTraceId, event, snapshot: { ...traceSnapshotSummary(snapshot), sessionId: streamSessionId, threadId: streamThreadId }, cursor: { traceSeq: eventSeq(event, Number(snapshot?.eventCount ?? 1) - 1) } }); } else { writeEvent("workbench.trace.snapshot", { type: "trace.snapshot", sessionId: streamSessionId, threadId: streamThreadId, traceId: activeTraceId, reason: "trace-store-update", snapshot: { ...traceSnapshotForRealtime(snapshot), sessionId: streamSessionId, threadId: streamThreadId }, cursor: { traceSeq: traceSnapshotLastSeq(snapshot) } }); } if (event?.terminal === true || TERMINAL_STATUSES.has(normalizeStatus(event?.status))) { void writeTraceRealtimeSnapshot({ writeEvent, options, actor: auth.actor, traceId: activeTraceId, reason: "terminal" }); } }); cleanup.push(unsubscribe); } const heartbeat = setInterval(async () => { try { writeEvent("workbench.heartbeat", { type: "heartbeat", sessionId: requestedSessionId ?? null, traceId: activeTraceId ?? null, observedAt: new Date().toISOString() }); } catch (error) { writeEvent("workbench.error", { type: "error", error: { code: "workbench_realtime_heartbeat_failed", message: error?.message ?? "Workbench realtime heartbeat failed." } }); } }, Math.max(1000, heartbeatMs)); cleanup.push(() => clearInterval(heartbeat)); response.on("close", () => { closed = true; for (const item of cleanup.splice(0)) item(); }); } function realtimeEventCreatedAt(payload) { const candidates = [payload?.event?.createdAt, payload?.snapshot?.lastEvent?.createdAt, payload?.turn?.updatedAt]; for (const value of candidates) { const text = textValue(value); if (text) return text; } return null; } function realtimeTraceSeq(payload) { const seq = Number(payload?.cursor?.traceSeq ?? payload?.event?.seq ?? payload?.snapshot?.lastEvent?.seq); return Number.isFinite(seq) && seq >= 0 ? seq : null; } function nowMs() { if (typeof performance !== "undefined" && typeof performance.now === "function") return performance.now(); return Date.now(); } function recordWorkbenchTurnReadMetric(options = {}, url, statusCode, body = {}, startedAt = nowMs()) { const performanceStore = options.backendPerformanceStore ?? options.backendPerformance; if (!performanceStore?.recordWorkbenchTurnRead) return; performanceStore.recordWorkbenchTurnRead({ route: url?.pathname ?? "/v1/workbench/turns/:id", status: body?.status ?? statusClassLabel(statusCode), degradedReason: workbenchTurnDegradedReason(body), durationMs: nowMs() - startedAt, responseBytes: responseBodyBytes(body), timedOut: statusCode === 504 || body?.error?.code === "timeout" }); } function workbenchTurnDegradedReason(body = {}) { if (body?.projection?.blocker?.code) return body.projection.blocker.code; if (body?.blocker?.code) return body.blocker.code; if (body?.error?.code) return body.error.code; if (body?.projectionStatus === "projecting") return "projecting"; if (body?.projectionStatus === "blocked") return "projection_blocked"; return "none"; } function responseBodyBytes(body = {}) { try { return Buffer.byteLength(JSON.stringify(body)); } catch { return 0; } } function statusClassLabel(value) { const status = Number(value); if (!Number.isFinite(status) || status <= 0) return "unknown"; return `${Math.floor(status / 100)}xx`; } async function authenticateWorkbenchRead(request, response, options) { const access = options.accessController; if (!access?.authenticate) { sendJson(response, 503, workbenchError("workbench_access_controller_missing", "Workbench read model requires access controller.")); return null; } await access.ensureBootstrap?.(); const auth = await access.authenticate(request, { required: true }); if (!auth?.ok) { sendJson(response, auth?.status ?? 401, auth ?? workbenchError("auth_required", "Authentication is required.")); return null; } return auth; } async function handleWorkbenchSessionList(response, url, options, actor) { if (url.searchParams.has("projectId") || url.searchParams.has("workspaceId")) return sendJson(response, 400, workbenchError("workbench_authority_removed", "Workbench session list is keyed by sessionId only.")); const limit = boundedLimit(url.searchParams.get("limit")); const offset = cursorOffset(url.searchParams.get("cursor") ?? url.searchParams.get("after")); const includeSessionId = safeSessionId(url.searchParams.get("includeSessionId")); const includeRouteId = includeSessionId ?? safeConversationId(url.searchParams.get("includeSessionId")); const readModel = createWorkbenchReadModel(options, actor); const naturalPage = await readModel.listSessions({ limit: limit + 1, offset }); const pageSessions = naturalPage.slice(0, limit); let responseSessions = pageSessions; if (includeRouteId && !pageSessions.some((session) => sessionMatchesRouteId(session, includeRouteId))) { const included = await readModel.getSessionByRouteId(includeRouteId); if (included) responseSessions = [included, ...pageSessions]; } const summaries = (await Promise.all(responseSessions.map(async (session) => sessionSummary(session, await sessionProjectionOptions(readModel, session, options))))).filter(Boolean); const hasMore = naturalPage.length > limit; sendJson(response, 200, { ok: true, status: "succeeded", contractVersion: "workbench-sessions-v1", sessions: summaries, count: summaries.length, cursor: offset > 0 ? cursorFromOffset(offset) : null, hasMore, nextCursor: hasMore ? cursorFromOffset(offset + limit) : null, valuesRedacted: true, secretMaterialStored: false }); } function sessionMatchesRouteId(session, routeId) { const sessionId = safeSessionId(routeId); if (sessionId && session?.id === sessionId) return true; const conversationId = safeConversationId(routeId); return Boolean(conversationId && session?.conversationId === conversationId); } async function handleWorkbenchSessionDetail(response, options, actor, sessionId) { const readModel = createWorkbenchReadModel(options, actor); const session = await readModel.getSessionByRouteId(sessionId); if (!session) return sendJson(response, 404, workbenchError("workbench_session_not_found", "Workbench session is not visible to the current actor.", { sessionId })); const projectionOptions = await sessionProjectionOptions(readModel, session, options); sendJson(response, 200, { ok: true, status: "found", contractVersion: "workbench-session-detail-v1", session: sessionDetail(session, projectionOptions), valuesRedacted: true, secretMaterialStored: false }); } async function handleWorkbenchMessagePage(response, url, options, actor, sessionId) { const readModel = createWorkbenchReadModel(options, actor); const session = await readModel.getSessionByRouteId(sessionId); if (!session) return sendJson(response, 404, workbenchError("workbench_session_not_found", "Workbench session is not visible to the current actor.", { sessionId })); const projectionOptions = await sessionProjectionOptions(readModel, session, options); const messages = sessionMessages(session, projectionOptions); const limit = boundedLimit(url.searchParams.get("limit")); const offset = cursorOffset(url.searchParams.get("cursor") ?? url.searchParams.get("after")); const page = messages.slice(offset, offset + limit); const nextOffset = offset + page.length; sendJson(response, 200, { ok: true, status: "succeeded", contractVersion: "workbench-message-page-v1", sessionId: session.id, messages: page, count: page.length, total: messages.length, cursor: offset > 0 ? cursorFromOffset(offset) : null, nextCursor: nextOffset < messages.length ? cursorFromOffset(nextOffset) : null, hasMore: nextOffset < messages.length, valuesRedacted: true, secretMaterialStored: false }); } async function handleWorkbenchTurnSnapshot(response, url, options, actor, rawTurnId) { const startedAt = nowMs(); const queryTraceId = safeTraceId(url.searchParams.get("traceId")); const traceId = safeTraceId(rawTurnId) ?? queryTraceId; const turnId = safeTurnId(rawTurnId) || traceId || rawTurnId; if (!traceId) { const body = workbenchError("invalid_turn_id", "turnId must currently be a trace id or include traceId query.", { turnId }); recordWorkbenchTurnReadMetric(options, url, 400, body, startedAt); return sendJson(response, 400, body); } const readModel = createWorkbenchReadModel(options, actor); const result = readModel.resultForTrace(traceId); const session = await readModel.getSessionByTraceId(traceId); if (result?.ownerUserId && !readModel.canReadOwner(result.ownerUserId)) { const body = workbenchError("agent_session_owner_required", "Only the session owner or admin can read this Workbench turn.", { traceId }); recordWorkbenchTurnReadMetric(options, url, 403, body, startedAt); return sendJson(response, 403, body); } const resultVisible = result && (session || actor.role === "admin" || Boolean(result.ownerUserId)); const trace = await readModel.traceSnapshot(traceId); const turnProjection = createWorkbenchTurnProjection({ turnId, traceId, result, session, trace }); const status = turnProjection.status; const found = Boolean(resultVisible || session); if (!found) { const body = workbenchError("workbench_turn_not_found", "Workbench turn is not visible to the current actor.", { turnId, traceId }); recordWorkbenchTurnReadMetric(options, url, 404, body, startedAt); return sendJson(response, 404, body); } const projection = readModel.projectionDiagnostics({ traceId, result, trace, projection: turnProjection }); const body = { ok: true, status, contractVersion: "workbench-turn-snapshot-v1", turn: turnSnapshot({ projection: turnProjection, result, session, trace }), projection, projectionStatus: projection.projectionStatus, projectionHealth: projection.projectionHealth, lastProjectedSeq: projection.lastProjectedSeq, sourceRunId: projection.sourceRunId, sourceCommandId: projection.sourceCommandId, staleMs: projection.staleMs, blocker: projection.blocker, valuesRedacted: true, secretMaterialStored: false }; recordWorkbenchTurnReadMetric(options, url, 200, body, startedAt); sendJson(response, 200, body); } async function handleWorkbenchTraceEventPage(response, url, options, actor, rawTraceId) { const traceId = safeTraceId(rawTraceId); if (!traceId) return sendJson(response, 400, workbenchError("invalid_trace_id", "traceId must start with trc_.", { traceId: rawTraceId })); const readModel = createWorkbenchReadModel(options, actor); const result = readModel.resultForTrace(traceId); const session = await readModel.getSessionByTraceId(traceId); if (result?.ownerUserId && !readModel.canReadOwner(result.ownerUserId)) { return sendJson(response, 403, workbenchError("agent_session_owner_required", "Only the session owner or admin can read this Workbench trace.", { traceId })); } const resultVisible = result && (session || actor.role === "admin" || Boolean(result.ownerUserId)); if (!resultVisible && !session) { return sendJson(response, 404, workbenchError("workbench_trace_not_found", "Workbench trace is not visible to the current actor.", { traceId })); } const trace = await readModel.traceSnapshot(traceId); const page = traceEventPage(trace, tracePageOptions(url)); const turnProjection = createWorkbenchTurnProjection({ traceId, result, session, trace }); const projection = readModel.projectionDiagnostics({ traceId, result, trace, projection: turnProjection }); sendJson(response, 200, { ok: true, status: "succeeded", contractVersion: "workbench-trace-events-v1", traceId, sessionId: safeSessionId(result?.sessionId ?? result?.session?.sessionId ?? session?.id) ?? null, threadId: safeOpaqueId(result?.threadId ?? result?.session?.threadId ?? session?.threadId) ?? (textValue(session?.threadId) || null), ...page, projection, projectionStatus: projection.projectionStatus, projectionHealth: projection.projectionHealth, lastProjectedSeq: projection.lastProjectedSeq, sourceRunId: projection.sourceRunId, sourceCommandId: projection.sourceCommandId, staleMs: projection.staleMs, blocker: projection.blocker, valuesRedacted: true, secretMaterialStored: false }); } async function visibleSessionById(store, sessionId, actor) { const safeId = safeSessionId(sessionId); if (!safeId) return null; const session = await store?.getAgentSession?.(safeId) ?? null; return canActorReadSession(session, actor) ? session : null; } async function visibleSessionByRouteId(store, routeId, actor) { const sessionId = safeSessionId(routeId); if (sessionId) return visibleSessionById(store, sessionId, actor); const conversationId = safeConversationId(routeId); if (!conversationId) return null; const listed = await store?.listAgentSessionsForUser?.({ ownerUserId: actor.id, actorRole: actor.role, ownerScoped: true, conversationId, limit: 1, includeArchived: false }) ?? []; const matched = listed.find((session) => canActorReadSession(session, actor)) ?? null; if (matched) return matched; const suffix = conversationId.slice("cnv_".length); const deterministicSessionId = safeSessionId(`ses_${suffix}`); return deterministicSessionId ? visibleSessionById(store, deterministicSessionId, actor) : null; } async function visibleSessionByTrace(store, traceId, actor) { const safeId = safeTraceId(traceId); if (!safeId) return null; const session = await store?.getAgentSessionByTraceId?.(safeId) ?? null; return canActorReadSession(session, actor) ? session : null; } async function sessionProjectionOptions(readModel, session, options) { const snapshot = objectValue(session?.session); const traceId = safeTraceId(session?.lastTraceId ?? snapshot.lastTraceId ?? snapshot.currentTraceId) ?? null; if (!traceId) return options; const trace = await readModel.traceSnapshot(traceId); const result = readModel.resultForTrace(traceId); const projection = createWorkbenchTurnProjection({ traceId, result, session, trace }); const projectionDiagnostic = readModel.projectionDiagnostics({ traceId, result, trace, projection }); return { ...options, trace, result, projection, projectionDiagnostic }; } function sessionSummary(session, options) { if (!session?.id) return null; const snapshot = objectValue(session.session); const currentTurn = currentWorkbenchTurnProjection(session, options); const projectionDiagnostic = options.projectionDiagnostic ?? null; const messages = sessionMessages(session, { ...options, currentTurn }); const status = currentTurn ? currentTurn.status : sessionLifecycleProjectionStatus(session); return { sessionId: session.id, threadId: safeOpaqueId(session.threadId) ?? (textValue(session.threadId) || null), agentId: session.agentId ?? "hwlab-code-agent", status, running: RUNNING_STATUSES.has(status), terminal: TERMINAL_STATUSES.has(status) && !RUNNING_STATUSES.has(status), lastTraceId: currentTurn ? currentTurn.traceId : null, projection: projectionDiagnostic, projectionStatus: projectionDiagnostic?.projectionStatus ?? null, projectionHealth: projectionDiagnostic?.projectionHealth ?? null, staleMs: projectionDiagnostic?.staleMs ?? null, blocker: projectionDiagnostic?.blocker ?? null, providerProfile: textValue(snapshot.providerProfile) || null, messageCount: messages.length, firstUserMessagePreview: firstUserPreview(messages), updatedAt: session.updatedAt ?? snapshot.updatedAt ?? null, turnSummary: currentTurn ? { turnId: currentTurn.turnId, traceId: currentTurn.traceId, status, running: RUNNING_STATUSES.has(status), terminal: TERMINAL_STATUSES.has(status) && !RUNNING_STATUSES.has(status), eventCount: currentTurn.eventCount, projection: projectionDiagnostic, projectionStatus: projectionDiagnostic?.projectionStatus ?? null, projectionHealth: projectionDiagnostic?.projectionHealth ?? null, staleMs: projectionDiagnostic?.staleMs ?? null, blocker: projectionDiagnostic?.blocker ?? null, updatedAt: currentTurn.updatedAt } : null, valuesRedacted: true }; } function sessionDetail(session, options) { return { ...sessionSummary(session, options), metadata: { startedAt: session.startedAt ?? null, endedAt: session.endedAt ?? null, ownerUserId: session.ownerUserId ?? null }, messagePageUrl: `/v1/workbench/sessions/${encodeURIComponent(session.id)}/messages`, valuesRedacted: true, secretMaterialStored: false }; } function sessionMessages(session, options = {}) { const snapshot = objectValue(session.session); const raw = Array.isArray(snapshot.messages) ? snapshot.messages : Array.isArray(snapshot.chatMessages) ? snapshot.chatMessages : []; const currentTurn = options.currentTurn ? options.currentTurn : currentWorkbenchTurnProjection(session, options); const finalTraceId = currentTurn ? currentTurn.traceId : null; const terminalProjection = terminalMessageProjection(snapshot, options, currentTurn); const messages = raw .map((message, index) => messageFact(message, index, session, snapshot)) .filter(Boolean) .map((message) => applyTerminalMessageProjection(message, terminalProjection)) .map((message) => applyProjectionMessageDiagnostic(message, options.projectionDiagnostic, currentTurn)); const hasAssistantLikeFinal = messages.some((message) => isAssistantLikeRole(message.role) && (!finalTraceId || message.traceId === finalTraceId)); const finalText = terminalProjection?.text ?? projectionText(snapshot.finalResponse); if (!hasAssistantLikeFinal && finalText) { const finalMessageStatus = terminalProjection ? terminalProjection.status : sessionLifecycleProjectionStatus(session); const finalMessageSource = terminalProjection ? terminalProjection.source : "finalResponse"; messages.push(messageFact({ role: "assistant", text: finalText, status: finalMessageStatus, traceId: finalTraceId, source: finalMessageSource }, messages.length, session, snapshot)); } return messages; } function applyProjectionMessageDiagnostic(message, diagnostic, currentTurn) { if (!diagnostic || !isAssistantLikeRole(message.role)) return message; if (currentTurn?.traceId && message.traceId && message.traceId !== currentTurn.traceId) return message; const trace = message.runnerTrace && typeof message.runnerTrace === "object" ? message.runnerTrace : null; return { ...message, projection: diagnostic, projectionStatus: diagnostic.projectionStatus ?? null, projectionHealth: diagnostic.projectionHealth ?? null, staleMs: diagnostic.staleMs ?? null, blocker: diagnostic.blocker ?? null, runnerTrace: trace ? { ...trace, projection: diagnostic, projectionStatus: diagnostic.projectionStatus ?? null, projectionHealth: diagnostic.projectionHealth ?? null, staleMs: diagnostic.staleMs ?? null, blocker: diagnostic.blocker ?? null } : trace }; } function terminalMessageProjection(snapshot, options, currentTurn) { if (!currentTurn) return null; const traceId = currentTurn.traceId; const result = options.result ?? options.codeAgentChatResults?.get?.(traceId) ?? null; const trace = currentTurn.trace; const status = currentTurn.status; if (!TERMINAL_STATUSES.has(status) || RUNNING_STATUSES.has(status)) return null; const text = projectionText(currentTurn.finalResponse, result?.finalResponse, result?.assistantText, result?.reply, result?.text, result?.summary, trace?.finalResponse, trace?.terminalEvidence?.finalResponse, snapshot.finalResponse); return { traceId, status, text, source: "turn-projection" }; } function applyTerminalMessageProjection(message, projection) { if (!projection || !isAssistantLikeRole(message.role)) return message; if (projection.traceId && message.traceId && message.traceId !== projection.traceId) return message; const text = projection.text || message.text || ""; const parts = projectTerminalMessageParts(message.parts, message.messageId, projection.traceId ?? message.traceId, projection, text); return { ...message, traceId: projection.traceId ?? message.traceId, turnId: message.turnId ?? projection.traceId ?? null, status: projection.status, parts, text, textPreview: text ? text.slice(0, 240) : message.textPreview ?? null, updatedAt: message.updatedAt ?? null }; } function projectTerminalMessageParts(parts, messageId, traceId, projection, text) { const sourceParts = Array.isArray(parts) ? parts : []; const textIndex = sourceParts.findIndex((part) => part?.type === "text" || part?.text); if (textIndex >= 0) { return sourceParts.map((part, index) => index === textIndex ? { ...part, traceId, text: text || part.text || null, status: projection.status } : part); } if (!text) return sourceParts; return [partFact({ type: "text", text, status: projection.status }, 0, messageId, traceId), ...sourceParts]; } function projectionText(...values) { for (const value of values) { if (value && typeof value === "object") { const nested = textValue(value.text ?? value.content ?? value.message ?? value.summary ?? value.preview ?? value.title); if (nested) return nested; continue; } const text = textValue(value); if (text) return text; } return null; } function messageFact(message, index, session, snapshot) { const role = textValue(message?.role) || (index % 2 === 0 ? "user" : "assistant"); const traceId = safeTraceId(message?.traceId ?? message?.turnTraceId ?? session.lastTraceId ?? snapshot.lastTraceId) ?? null; const messageId = safeMessageId(message?.messageId ?? message?.id) || `msg_${hash(`${session.id}:${index}:${role}:${traceId ?? "none"}`).slice(0, 24)}`; const text = projectionText(message?.text, message?.content, message?.message, message?.finalResponse); const rawParts = Array.isArray(message?.parts) ? message.parts : []; const parts = rawParts.length > 0 ? rawParts.map((part, partIndex) => partFact(part, partIndex, messageId, traceId)).filter(Boolean) : text ? [partFact({ type: "text", text }, 0, messageId, traceId)] : []; return { messageId, role, sessionId: session.id, traceId, turnId: safeTurnId(message?.turnId) || traceId, status: normalizeStatus(message?.status ?? (isAssistantLikeRole(role) ? session.status : "completed")), parts, text: text || "", textPreview: text ? text.slice(0, 240) : null, createdAt: textValue(message?.createdAt ?? message?.timestamp ?? message?.at) || session.startedAt || session.updatedAt || null, updatedAt: textValue(message?.updatedAt) || session.updatedAt || null, valuesRedacted: message?.valuesRedacted !== false }; } function partFact(part, index, messageId, traceId) { const type = textValue(part?.type) || "text"; const text = textValue(part?.text ?? part?.content ?? part?.message); return { partId: safePartId(part?.partId ?? part?.id) || `prt_${hash(`${messageId}:${index}:${type}`).slice(0, 24)}`, messageId, traceId, type, text: text || null, status: normalizeStatus(part?.status ?? "completed"), toolName: textValue(part?.toolName ?? part?.name) || null, createdAt: textValue(part?.createdAt ?? part?.timestamp) || null, valuesRedacted: part?.valuesRedacted !== false }; } function turnSnapshot({ projection, result, session, trace }) { const turnId = projection.turnId; const traceId = projection.traceId; const status = projection.status; const messages = session ? sessionMessages(session, { result, trace }) : []; const userMessage = messages.find((message) => message.role === "user") ?? null; const assistantMessage = [...messages].reverse().find((message) => isAssistantLikeRole(message.role)) ?? null; const finalText = projectionText(projection.finalResponse); return { turnId, traceId, status, running: RUNNING_STATUSES.has(status), terminal: TERMINAL_STATUSES.has(status) && !RUNNING_STATUSES.has(status), sessionId: safeSessionId(result?.sessionId ?? result?.session?.sessionId ?? session?.id) ?? null, threadId: safeOpaqueId(result?.threadId ?? result?.session?.threadId ?? session?.threadId) ?? (textValue(session?.threadId) || null), userMessageId: userMessage?.messageId ?? null, assistantMessageId: assistantMessage?.messageId ?? null, assistantText: finalText ?? null, finalResponse: projection.finalResponse ?? null, agentRun: result?.agentRun ?? null, trace: { traceId, status: trace?.status ?? "missing", eventCount: trace?.eventCount ?? 0, updatedAt: trace?.updatedAt ?? null }, urls: { self: `/v1/workbench/turns/${encodeURIComponent(turnId)}`, traceEvents: `/v1/workbench/traces/${encodeURIComponent(traceId)}/events` } }; } function traceEventPage(snapshot, options) { const sourceEvents = Array.isArray(snapshot?.events) ? snapshot.events : []; const indexed = sourceEvents.map((event, index) => ({ event, seq: eventSeq(event, index) })); const startIndex = indexed.findIndex((item) => item.seq > options.afterSeq); const offset = startIndex >= 0 ? startIndex : indexed.length; const page = indexed.slice(offset, offset + options.limit); const events = page.map((item) => ({ ...item.event, seq: item.seq })); const toSeq = page.length ? page[page.length - 1].seq : options.afterSeq; const hasMore = offset + page.length < indexed.length; return { events, eventCount: indexed.length, range: { afterSeq: options.afterSeq, fromSeq: page.length ? page[0].seq : null, toSeq, limit: options.limit, returned: events.length, total: indexed.length }, hasMore, nextSeq: toSeq, nextCursor: hasMore ? `seq:${toSeq}` : null, traceStatus: snapshot?.status ?? "missing", updatedAt: snapshot?.updatedAt ?? null }; } function tracePageOptions(url) { const cursor = textValue(url.searchParams.get("cursor")); const cursorSeq = cursor.startsWith("seq:") ? Number.parseInt(cursor.slice(4), 10) : NaN; const afterSeq = Number.isInteger(cursorSeq) ? cursorSeq : nonNegativeInteger(url.searchParams.get("afterSeq") ?? url.searchParams.get("sinceSeq") ?? url.searchParams.get("since"), 0); return { afterSeq, limit: boundedLimit(url.searchParams.get("limit")) }; } function traceSnapshotSync(options, traceId) { return (options.traceStore ?? defaultCodeAgentTraceStore).snapshot(traceId); } async function writeTraceRealtimeSnapshot({ writeEvent, options, actor, traceId, reason }) { const context = await visibleTraceContext(options, actor, traceId); if (!context.visible) { writeEvent("workbench.trace.unavailable", { type: "trace.unavailable", reason, traceId, error: { code: "workbench_trace_not_found", message: "Workbench trace is not visible to the current actor." } }); return; } const sessionId = safeSessionId(context.result?.sessionId ?? context.result?.session?.sessionId ?? context.session?.id) ?? null; const threadId = safeOpaqueId(context.result?.threadId ?? context.result?.session?.threadId ?? context.session?.threadId) ?? (textValue(context.session?.threadId) || null); writeEvent("workbench.trace.snapshot", { type: "trace.snapshot", reason, sessionId, threadId, traceId, snapshot: { ...traceSnapshotForRealtime(context.trace), sessionId, threadId }, cursor: { traceSeq: traceSnapshotLastSeq(context.trace) } }); writeEvent("workbench.turn.snapshot", { type: "turn.snapshot", reason, sessionId, threadId, traceId, turn: turnSnapshot(context), cursor: { traceSeq: traceSnapshotLastSeq(context.trace) } }); } async function visibleTraceContext(options, actor, traceId) { const readModel = createWorkbenchReadModel(options, actor); const result = readModel.resultForTrace(traceId); const session = await readModel.getSessionByTraceId(traceId); if (result?.ownerUserId && !readModel.canReadOwner(result.ownerUserId)) return { visible: false }; const resultVisible = result && (session || actor.role === "admin" || Boolean(result.ownerUserId)); if (!resultVisible && !session) return { visible: false }; const trace = await readModel.traceSnapshot(traceId); const projection = createWorkbenchTurnProjection({ turnId: traceId, traceId, result, session, trace }); return { visible: true, turnId: projection.turnId, traceId, status: projection.status, projection, result, session, trace }; } function traceSnapshotForRealtime(snapshot) { const events = Array.isArray(snapshot?.events) ? snapshot.events : []; return { ...snapshot, events, eventCount: Number.isFinite(Number(snapshot?.eventCount)) ? Number(snapshot.eventCount) : events.length, lastEvent: snapshot?.lastEvent ?? events.at(-1) ?? null, valuesRedacted: true, secretMaterialStored: false }; } function traceSnapshotSummary(snapshot) { const events = Array.isArray(snapshot?.events) ? snapshot.events : []; return { traceId: snapshot?.traceId ?? null, status: snapshot?.status ?? "missing", eventCount: Number.isFinite(Number(snapshot?.eventCount)) ? Number(snapshot.eventCount) : events.length, lastEvent: snapshot?.lastEvent ?? events.at(-1) ?? null, updatedAt: snapshot?.updatedAt ?? null, valuesRedacted: true }; } function traceSnapshotLastSeq(snapshot) { const events = Array.isArray(snapshot?.events) ? snapshot.events : []; return events.reduce((max, event, index) => Math.max(max, eventSeq(event, index)), 0); } function currentWorkbenchTurnProjection(session, options = {}) { const traceId = safeTraceId(session?.lastTraceId) ?? null; if (!traceId) return null; const trace = options.trace?.traceId === traceId ? options.trace : traceSnapshotSync(options, traceId); const result = traceScopedResult(options, traceId); return createWorkbenchTurnProjection({ turnId: traceId, traceId, result, session, trace }); } function traceScopedResult(options, traceId) { const result = options.result?.traceId === traceId || (!options.result?.traceId && options.result) ? options.result : options.codeAgentChatResults?.get?.(traceId) ?? null; return result && typeof result === "object" ? result : null; } function sessionLifecycleProjectionStatus(session) { return normalizeStatus(session?.status); } function canActorReadSession(session, actor) { if (!session || !actor) return false; if (normalizeStatus(session.status) === "archived") return false; if (actor.role === "admin") return true; return session.ownerUserId === actor.id; } function canActorReadOwner(ownerUserId, actor) { if (!ownerUserId || !actor) return true; if (actor.role === "admin") return true; return ownerUserId === actor.id; } function methodNotAllowed(response, allowed) { sendJson(response, 405, workbenchError("method_not_allowed", `Use ${allowed} for this Workbench read model route.`)); } function workbenchError(code, message, extra = {}) { return { ok: false, status: "failed", error: { code, message, ...extra }, valuesRedacted: true, secretMaterialStored: false }; } function boundedLimit(value) { return Math.min(MAX_PAGE_LIMIT, parsePositiveInteger(value, DEFAULT_PAGE_LIMIT)); } function cursorOffset(value) { const text = textValue(value); if (!text) return 0; if (text.startsWith("idx:")) return nonNegativeInteger(text.slice(4), 0); return nonNegativeInteger(text, 0); } function cursorFromOffset(offset) { return `idx:${Math.max(0, Math.trunc(Number(offset) || 0))}`; } function nonNegativeInteger(value, fallback) { const parsed = Number.parseInt(value ?? "", 10); return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback; } function eventSeq(event, index) { const seq = Number(event?.seq); return Number.isFinite(seq) && seq > 0 ? Math.trunc(seq) : index + 1; } function normalizeStatus(value) { const text = textValue(value).toLowerCase().replace(/_/gu, "-"); if (text === "cancelled") return "canceled"; return text || "unknown"; } function firstUserPreview(messages) { return messages.find((message) => message.role === "user")?.textPreview ?? null; } function isAssistantLikeRole(role) { return role === "assistant" || role === "agent"; } function objectValue(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } function textValue(value) { return String(value ?? "").trim(); } function safeTurnId(value) { const text = textValue(value); return /^turn_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null; } function safeMessageId(value) { const text = textValue(value); return /^msg_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null; } function safePartId(value) { const text = textValue(value); return /^prt_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null; } function hash(value) { return createHash("sha256").update(String(value)).digest("hex"); }