313 lines
13 KiB
TypeScript
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
|
|
});
|
|
}
|