Files
pikasTech-HWLAB/internal/cloud/workbench-facts-store.ts
T

301 lines
11 KiB
TypeScript

/*
* SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-010403 API契约 draft-2026-06-18-r1.
* 职责: Workbench durable facts store adapter。统一读取 session/message/turn/trace projection facts,不在 GET 中推进 AgentRun facts。
*/
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts";
import { safeConversationId, safeSessionId, safeTraceId } from "./server-http-utils.ts";
import { durableTraceStatus, normalizeWorkbenchStatus, TERMINAL_STATUSES, traceTerminalEvidence } from "./workbench-turn-projection.ts";
const TERMINAL_DURABLE_TRACE_CACHE_MAX = 256;
const terminalDurableTraceCache = new Map();
export function createWorkbenchFactsStore(options = {}, actor = null) {
const accessStore = options.accessController?.store ?? options.accessController ?? null;
const traceSessionCache = options.codeAgentTraceSessionCache instanceof Map ? options.codeAgentTraceSessionCache : null;
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
const runtimeStore = options.runtimeStore ?? null;
const results = options.codeAgentChatResults ?? null;
async function queryFacts(params = {}) {
if (typeof runtimeStore?.queryWorkbenchFacts === "function") {
try {
const result = await runtimeStore.queryWorkbenchFacts(params);
return {
facts: normalizeWorkbenchFactsResult(result?.facts),
count: Number.isFinite(Number(result?.count)) ? Number(result.count) : 0,
durable: true,
persistence: result?.persistence ?? null
};
} catch (error) {
return {
facts: emptyWorkbenchFacts(),
count: 0,
durable: true,
error,
projection: projectionStoreUnavailableTrace(safeTraceId(params.traceId) ?? "trc_unassigned", error)
};
}
}
return {
facts: emptyWorkbenchFacts(),
count: 0,
durable: false,
persistence: null
};
}
async function getSessionById(sessionId) {
const safeId = safeSessionId(sessionId);
if (!safeId) return null;
const session = await accessStore?.getAgentSession?.(safeId) ?? null;
return canActorReadSession(session, actor) ? session : null;
}
async function getSessionByRouteId(routeId) {
const sessionId = safeSessionId(routeId);
if (sessionId) return getSessionById(sessionId);
const conversationId = safeConversationId(routeId);
if (!conversationId) return null;
const listed = await accessStore?.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 ? getSessionById(deterministicSessionId) : null;
}
async function getSessionByTraceId(traceId) {
const safeId = safeTraceId(traceId);
if (!safeId) return null;
if (traceSessionCache?.has(safeId)) {
const cached = traceSessionCache.get(safeId) ?? null;
return canActorReadSession(cached, actor) ? cached : null;
}
const session = await accessStore?.getAgentSessionByTraceId?.(safeId) ?? null;
traceSessionCache?.set(safeId, session);
return canActorReadSession(session, actor) ? session : null;
}
async function listSessions({ limit, offset = 0, includeRouteId } = {}) {
let sessions = await accessStore?.listAgentSessionsForUser?.({
ownerUserId: actor?.id,
actorRole: actor?.role,
ownerScoped: true,
limit,
offset,
includeArchived: false
}) ?? [];
const includeSessionId = safeSessionId(includeRouteId);
if (includeRouteId && !sessions.some((session) => session.id === includeSessionId)) {
const included = await getSessionByRouteId(includeRouteId);
if (included) sessions = [included, ...sessions];
}
return sessions.filter((session) => canActorReadSession(session, actor));
}
function resultForTrace(traceId) {
const safeId = safeTraceId(traceId);
return safeId ? results?.get?.(safeId) ?? null : null;
}
function traceSnapshotSync(traceId) {
return traceStore.snapshot(traceId);
}
async function traceSnapshot(traceId) {
const memory = traceSnapshotSync(traceId);
if (hasTraceProjection(memory) && TERMINAL_STATUSES.has(normalizeStatus(memory.status))) return memory;
const durable = await durableTraceSnapshot(runtimeStore, traceId);
if (isProjectionDiagnosticTrace(durable) && hasTraceProjection(memory)) return mergeTraceProjectionDiagnostic(memory, durable);
if (durable && shouldPreferDurableTrace(memory, durable)) return durable;
if (hasTraceProjection(memory)) return memory;
return durable ?? memory;
}
function subscribeTrace(traceId, listener) {
return traceStore.subscribe(traceId, listener);
}
return {
queryFacts,
getSessionById,
getSessionByRouteId,
getSessionByTraceId,
listSessions,
resultForTrace,
traceSnapshot,
traceSnapshotSync,
subscribeTrace,
canReadOwner: (ownerUserId) => canActorReadOwner(ownerUserId, actor)
};
}
async function durableTraceSnapshot(runtimeStore, traceId) {
if (!runtimeStore || typeof runtimeStore.queryAgentTraceEvents !== "function") return null;
const safeId = safeTraceId(traceId);
if (!safeId) return null;
const cached = terminalDurableTraceCache.get(safeId);
if (cached) {
terminalDurableTraceCache.delete(safeId);
terminalDurableTraceCache.set(safeId, cached);
return cached;
}
let result;
try {
result = await runtimeStore.queryAgentTraceEvents({ traceId: safeId });
} catch (error) {
return projectionStoreUnavailableTrace(safeId, error);
}
const events = Array.isArray(result?.events) ? result.events : [];
if (events.length === 0) return null;
const normalizedEvents = events.map((event, index) => ({ ...event, traceId: safeId, seq: eventSeq(event, index) }));
const firstEvent = normalizedEvents[0] ?? null;
const lastEvent = normalizedEvents.at(-1) ?? null;
const status = durableTraceStatus(normalizedEvents);
const terminalEvidence = traceTerminalEvidence({ events: normalizedEvents, status });
const snapshot = {
traceId: safeId,
status,
createdAt: firstEvent?.createdAt ?? null,
updatedAt: lastEvent?.createdAt ?? firstEvent?.createdAt ?? null,
startedAt: firstEvent?.createdAt ?? null,
finishedAt: TERMINAL_STATUSES.has(status) ? lastEvent?.createdAt ?? null : null,
eventCount: normalizedEvents.length,
events: normalizedEvents,
assistantStreams: [],
eventLabels: normalizedEvents.map((event) => event.label).filter(Boolean),
lastEvent,
terminalEvidence,
outputTruncated: normalizedEvents.some((event) => event.outputTruncated === true),
valuesPrinted: false
};
rememberTerminalDurableTrace(snapshot);
return snapshot;
}
function rememberTerminalDurableTrace(snapshot) {
const traceId = safeTraceId(snapshot?.traceId);
if (!traceId || !TERMINAL_STATUSES.has(normalizeStatus(snapshot?.status))) return;
terminalDurableTraceCache.delete(traceId);
terminalDurableTraceCache.set(traceId, snapshot);
while (terminalDurableTraceCache.size > TERMINAL_DURABLE_TRACE_CACHE_MAX) {
const oldest = terminalDurableTraceCache.keys().next().value;
terminalDurableTraceCache.delete(oldest);
}
}
function projectionStoreUnavailableTrace(traceId, error) {
const message = "运行记录投影存储暂不可读,Trace 更新暂不可见。";
const projection = {
projectionStatus: "blocked",
projectionHealth: "unavailable",
blocker: {
code: "projection_store_unavailable",
layer: "runtime-store",
category: "projection-store-unavailable",
message,
causeCode: error?.code ?? null,
retryable: true,
valuesPrinted: false
},
valuesRedacted: true
};
return {
traceId,
status: "missing",
eventCount: 0,
events: [],
projection,
projectionStatus: projection.projectionStatus,
projectionHealth: projection.projectionHealth,
blocker: projection.blocker,
updatedAt: new Date().toISOString(),
valuesPrinted: false
};
}
function isProjectionDiagnosticTrace(snapshot) {
return Boolean(snapshot?.projection?.blocker || snapshot?.projectionHealth || snapshot?.blocker);
}
function mergeTraceProjectionDiagnostic(memory, diagnostic) {
return {
...memory,
projection: diagnostic.projection ?? memory?.projection ?? null,
projectionStatus: diagnostic.projectionStatus ?? diagnostic.projection?.projectionStatus ?? memory?.projectionStatus ?? null,
projectionHealth: diagnostic.projectionHealth ?? diagnostic.projection?.projectionHealth ?? memory?.projectionHealth ?? null,
blocker: diagnostic.blocker ?? diagnostic.projection?.blocker ?? memory?.blocker ?? null,
valuesPrinted: false
};
}
function hasTraceProjection(snapshot) {
return Boolean(snapshot?.status !== "missing" && (Number(snapshot?.eventCount ?? 0) > 0 || snapshot?.lastEvent));
}
function shouldPreferDurableTrace(memory, durable) {
if (!durable) return false;
if (!hasTraceProjection(memory)) return true;
const memoryStatus = normalizeWorkbenchStatus(memory?.status);
const durableStatus = normalizeWorkbenchStatus(durable?.status);
if (TERMINAL_STATUSES.has(durableStatus) && !TERMINAL_STATUSES.has(memoryStatus)) return true;
return traceLastSeq(durable) > traceLastSeq(memory);
}
function traceLastSeq(snapshot) {
const events = Array.isArray(snapshot?.events) ? snapshot.events : [];
const lastEvent = snapshot?.lastEvent ?? events.at(-1) ?? null;
const indexedMax = events.reduce((max, event, index) => Math.max(max, eventSeq(event, index)), 0);
return Math.max(indexedMax, lastEvent ? eventSeq(lastEvent, Math.max(0, events.length - 1)) : 0);
}
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 eventSeq(event, index) {
const seq = Number(event?.seq);
return Number.isFinite(seq) && seq > 0 ? Math.trunc(seq) : index + 1;
}
function normalizeStatus(value) {
const text = String(value ?? "").trim().toLowerCase().replace(/_/gu, "-");
if (text === "cancelled") return "canceled";
return text || "unknown";
}
function normalizeWorkbenchFactsResult(facts = {}) {
const normalized = emptyWorkbenchFacts();
for (const key of Object.keys(normalized)) {
normalized[key] = Array.isArray(facts?.[key]) ? facts[key].map((item) => ({ ...item })) : [];
}
return normalized;
}
function emptyWorkbenchFacts() {
return {
sessions: [],
messages: [],
parts: [],
turns: [],
traceEvents: [],
checkpoints: []
};
}