/* * SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-010205 HWLAB接入 draft-2026-06-17-r0. * 职责: WorkbenchProjectionWriter 组件入口。唯一封装 Code Agent/AgentRun facts 到 Workbench session projection facts 的持久化写入。 */ import { createHash } from "node:crypto"; import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; import { safeTraceId } from "./server-http-utils.ts"; import { createWorkbenchTurnProjection, normalizeWorkbenchStatus, projectionDiagnostics, TERMINAL_STATUSES } from "./workbench-turn-projection.ts"; export async function writeWorkbenchProjectionSession({ accessController, runtimeStore = null, traceStore = defaultCodeAgentTraceStore, traceId, ownerUserId, ownerRole = null, sessionId = null, projectId = null, conversationId = null, threadId = null, status = "active", session = {}, payload = null, params = {}, preserveLastTraceId = false } = {}) { if (!ownerUserId || typeof accessController?.recordAgentSessionOwner !== "function") return null; const safeId = safeTraceId(traceId); try { const ownerRecord = await accessController.recordAgentSessionOwner({ ownerUserId, ownerRole, sessionId, projectId, conversationId, threadId, traceId: preserveLastTraceId ? undefined : safeId, status, session }); await writeWorkbenchProjectionFacts({ runtimeStore, traceStore, traceId: safeId, ownerUserId, ownerRole, sessionId: ownerRecord?.id ?? sessionId, projectId: ownerRecord?.projectId ?? projectId, conversationId: ownerRecord?.conversationId ?? conversationId, threadId: ownerRecord?.threadId ?? threadId, status: ownerRecord?.status ?? status, session: ownerRecord?.session ?? session, payload, params }); return ownerRecord; } catch (error) { if (safeId) appendProjectionDiagnostic(traceStore, safeId, { type: "session-owner", status: "degraded", label: "projection-writer:session-owner:persist-failed", errorCode: "workbench_projection_session_persist_failed", message: error?.message ?? "Workbench projection session persistence failed.", valuesPrinted: false }); return null; } } export async function writeWorkbenchProjectionEvent({ runtimeStore = null, event = {}, requestMeta = {} } = {}) { if (typeof runtimeStore?.writeWorkbenchFacts !== "function") return null; const traceId = safeTraceId(event.traceId ?? requestMeta.traceId); if (!traceId) return null; const sourceSeq = nonNegativeInteger(event.sourceSeq ?? event.seq); const projectedSeq = nonNegativeInteger(event.seq ?? event.projectedSeq ?? sourceSeq); const occurredAt = timestampValue(event.createdAt ?? event.occurredAt ?? event.updatedAt); const sessionId = textValue(event.sessionId ?? requestMeta.sessionId) || null; const turnId = textValue(event.turnId) || traceId; const terminal = event.terminal === true || TERMINAL_STATUSES.has(normalizeWorkbenchStatus(event.status)); const status = terminal ? normalizeWorkbenchStatus(event.status) : "running"; const sourceEventId = textValue(event.sourceEventId ?? event.id) || `${traceId}:${sourceSeq || projectedSeq}`; const eventId = textValue(event.id) || stableFactId("wte", { traceId, sourceEventId, sourceSeq, projectedSeq, label: event.label, occurredAt }); const facts = { sessions: sessionId ? [{ sessionId, status, lastTraceId: traceId, projectedSeq, sourceSeq, sourceEventId, terminal, sealed: terminal, updatedAt: occurredAt }] : [], traceEvents: [{ ...event, id: eventId, traceId, sessionId, turnId, messageId: textValue(event.messageId) || null, sourceSeq, sourceEventId, projectedSeq, eventType: textValue(event.eventType ?? event.type ?? event.label) || "event", terminal, sealed: terminal, occurredAt, updatedAt: timestampValue(event.updatedAt ?? occurredAt) }], checkpoints: [{ traceId, sessionId, turnId, runId: textValue(event.runId) || null, commandId: textValue(event.commandId) || null, projectedSeq, sourceSeq, sourceEventId, projectionStatus: terminal ? "caught_up" : "projecting", projectionHealth: "healthy", terminal, sealed: terminal, diagnostic: { lastEventLabel: textValue(event.label) || null, lastEventStatus: textValue(event.status) || null, valuesRedacted: true }, updatedAt: timestampValue(event.updatedAt ?? occurredAt) }] }; return runtimeStore.writeWorkbenchFacts({ facts }, { traceId, sessionId, agentSessionId: requestMeta.agentSessionId ?? sessionId, valuesPrinted: false }); } async function writeWorkbenchProjectionFacts({ runtimeStore = null, traceStore = defaultCodeAgentTraceStore, traceId = null, ownerUserId = null, ownerRole = null, sessionId = null, projectId = null, conversationId = null, threadId = null, status = "active", session = {}, payload = null, params = {} } = {}) { if (typeof runtimeStore?.writeWorkbenchFacts !== "function") return null; const facts = buildWorkbenchProjectionFacts({ traceId, ownerUserId, ownerRole, sessionId, projectId, conversationId, threadId, status, session, payload, params }); if (isEmptyFacts(facts)) return null; try { return await runtimeStore.writeWorkbenchFacts({ facts }, { traceId: facts.checkpoints[0]?.traceId ?? traceId, sessionId: facts.sessions[0]?.sessionId ?? sessionId, ownerUserId, projectId, valuesPrinted: false }); } catch (error) { const safeId = safeTraceId(traceId); if (safeId) appendProjectionDiagnostic(traceStore, safeId, { type: "facts", status: "degraded", label: "projection-writer:facts:persist-failed", errorCode: error?.code ?? "workbench_facts_persist_failed", message: error?.message ?? "Workbench facts persistence failed.", terminal: false }); return null; } } function buildWorkbenchProjectionFacts({ traceId = null, ownerUserId = null, ownerRole = null, sessionId = null, projectId = null, conversationId = null, threadId = null, status = "active", session = {}, payload = null, params = {} } = {}) { const safeId = safeTraceId(traceId ?? session?.traceId ?? payload?.traceId ?? params?.traceId); const resolvedSessionId = textValue(sessionId ?? session?.sessionId ?? payload?.sessionId ?? params?.sessionId) || null; const resolvedConversationId = textValue(conversationId ?? session?.conversationId ?? payload?.conversationId ?? params?.conversationId) || null; const resolvedThreadId = textValue(threadId ?? session?.threadId ?? payload?.threadId ?? params?.threadId) || null; const agentRun = payload?.agentRun ?? session?.agentRun ?? null; const timestamp = timestampValue(payload?.updatedAt ?? session?.updatedAt ?? payload?.createdAt ?? session?.createdAt); const normalizedStatus = normalizeWorkbenchStatus(payload?.status ?? session?.sessionStatus ?? status); const terminal = TERMINAL_STATUSES.has(normalizedStatus); const projection = createWorkbenchTurnProjection({ traceId: safeId, result: payload, session: { id: resolvedSessionId, status: normalizedStatus, session }, trace: payload?.runnerTrace ?? null }); const diagnostic = projectionDiagnostics({ traceId: safeId, result: payload, trace: payload?.runnerTrace ?? null, projection }); const messages = normalizeMessages(session?.messages, { traceId: safeId, sessionId: resolvedSessionId, conversationId: resolvedConversationId, threadId: resolvedThreadId, terminal, timestamp }); return { sessions: resolvedSessionId ? [{ sessionId: resolvedSessionId, ownerUserId, ownerRole, projectId: projectId ?? session?.projectId ?? payload?.projectId ?? params?.projectId ?? null, conversationId: resolvedConversationId, threadId: resolvedThreadId, status: terminal ? normalizedStatus : "running", lastTraceId: safeId, projectedSeq: nonNegativeInteger(projection.lastProjectedSeq), sourceSeq: nonNegativeInteger(projection.lastProjectedSeq), sourceEventId: safeId, terminal, sealed: terminal, sessionJson: session, createdAt: timestampValue(session?.createdAt ?? payload?.createdAt ?? timestamp), updatedAt: timestamp }] : [], messages, parts: messages.flatMap((message) => message.text ? [{ partId: stableFactId("wpt", { messageId: message.messageId, index: 0 }), messageId: message.messageId, sessionId: message.sessionId, turnId: message.turnId, traceId: message.traceId, partIndex: 0, partType: "text", status: message.status, projectedSeq: message.projectedSeq, sourceSeq: message.sourceSeq, sourceEventId: message.sourceEventId, terminal: message.terminal, sealed: message.sealed, text: message.text, updatedAt: message.updatedAt }] : []), turns: safeId && resolvedSessionId ? [{ turnId: projection.turnId ?? safeId, sessionId: resolvedSessionId, traceId: safeId, messageId: messages.find((message) => message.role !== "user")?.messageId ?? null, status: projection.status ?? normalizedStatus, projectedSeq: nonNegativeInteger(projection.lastProjectedSeq), sourceSeq: nonNegativeInteger(projection.lastProjectedSeq), sourceEventId: safeId, terminal: projection.terminal, sealed: projection.terminal, finalResponse: projection.finalResponse, diagnostic, updatedAt: timestamp }] : [], checkpoints: safeId ? [{ traceId: safeId, sessionId: resolvedSessionId, turnId: projection.turnId ?? safeId, runId: textValue(agentRun?.runId ?? projection.sourceRunId) || null, commandId: textValue(agentRun?.commandId ?? projection.sourceCommandId) || null, projectedSeq: nonNegativeInteger(projection.lastProjectedSeq), sourceSeq: nonNegativeInteger(agentRun?.lastSeq ?? projection.lastProjectedSeq), sourceEventId: safeId, projectionStatus: projection.terminal ? "caught_up" : "projecting", projectionHealth: diagnostic.projectionHealth === "unavailable" ? "unavailable" : diagnostic.projectionHealth === "stalled" ? "stalled" : "healthy", terminal: projection.terminal, sealed: projection.terminal, diagnostic, updatedAt: timestamp }] : [] }; } function normalizeMessages(messages, context) { if (!Array.isArray(messages)) return []; return messages.map((message, index) => normalizeMessageFact(message, index, context)).filter(Boolean); } function normalizeMessageFact(message = {}, index, { traceId, sessionId, conversationId, threadId, terminal, timestamp }) { const messageId = textValue(message.messageId ?? message.id) || stableFactId("msg", { traceId, index, role: message.role }); if (!sessionId || !messageId) return null; const role = textValue(message.role) || "agent"; const status = normalizeWorkbenchStatus(message.status ?? (terminal && role !== "user" ? "completed" : role === "user" ? "sent" : "running")); const messageTerminal = role !== "user" && TERMINAL_STATUSES.has(status); return { ...message, messageId, sessionId, conversationId, threadId, turnId: textValue(message.turnId) || traceId, traceId: safeTraceId(message.traceId ?? traceId) ?? traceId, role, status, projectedSeq: nonNegativeInteger(message.projectedSeq ?? message.seq ?? index + 1), sourceSeq: nonNegativeInteger(message.sourceSeq ?? message.seq ?? index + 1), sourceEventId: textValue(message.sourceEventId) || `${messageId}:0`, terminal: messageTerminal, sealed: messageTerminal, text: textValue(message.text ?? message.content ?? message.message), createdAt: timestampValue(message.createdAt ?? timestamp), updatedAt: timestampValue(message.updatedAt ?? timestamp), valuesPrinted: false }; } function isEmptyFacts(facts) { return Object.values(facts).every((items) => !Array.isArray(items) || items.length === 0); } function stableFactId(prefix, value) { return `${prefix}_${createHash("sha256").update(JSON.stringify(sortJson(value))).digest("hex").slice(0, 32)}`; } function sortJson(value) { if (Array.isArray(value)) return value.map(sortJson); if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])])); return value; } function nonNegativeInteger(value) { const parsed = Number.parseInt(value ?? "", 10); return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0; } function timestampValue(value) { const ms = Date.parse(String(value ?? "")); return Number.isFinite(ms) ? new Date(ms).toISOString() : new Date().toISOString(); } function textValue(value) { return String(value ?? "").trim(); } export function appendProjectionDiagnostic(traceStore = defaultCodeAgentTraceStore, traceId, event = {}) { const safeId = safeTraceId(traceId); if (!safeId || typeof traceStore?.append !== "function") return null; return traceStore.append(safeId, { type: "projection-diagnostic", status: "degraded", label: "projection:diagnostic", ...event, valuesPrinted: false }); }