Files
pikasTech-HWLAB/internal/cloud/workbench-projection-writer.ts
T
2026-06-20 08:57:21 +08:00

313 lines
13 KiB
TypeScript

/*
* 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
});
}