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

189 lines
7.3 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";
export function createWorkbenchFactsStore(options = {}, actor = null) {
const accessStore = options.accessController?.store ?? options.accessController ?? null;
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
const runtimeStore = options.runtimeStore ?? null;
const results = options.codeAgentChatResults ?? 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;
const session = await accessStore?.getAgentSessionByTraceId?.(safeId) ?? null;
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 (durable && shouldPreferDurableTrace(memory, durable)) return durable;
if (hasTraceProjection(memory)) return memory;
return durable ?? memory;
}
function subscribeTrace(traceId, listener) {
return traceStore.subscribe(traceId, listener);
}
return {
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;
let result;
try {
result = await runtimeStore.queryAgentTraceEvents({ traceId });
} catch {
return null;
}
const events = Array.isArray(result?.events) ? result.events : [];
if (events.length === 0) return null;
const normalizedEvents = events.map((event, index) => ({ ...event, traceId, seq: eventSeq(event, index) }));
const firstEvent = normalizedEvents[0] ?? null;
const lastEvent = normalizedEvents.at(-1) ?? null;
const status = durableTraceStatus(normalizedEvents);
return {
traceId,
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,
outputTruncated: normalizedEvents.some((event) => event.outputTruncated === true),
valuesPrinted: false
};
}
const TERMINAL_STATUSES = new Set(["completed", "failed", "blocked", "timeout", "cancelled", "canceled", "idle"]);
const RUNNING_STATUSES = new Set(["running", "pending", "queued", "accepted", "dispatching", "streaming", "active"]);
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 = normalizeStatus(memory?.status);
const durableStatus = normalizeStatus(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 durableTraceStatus(events) {
const lastEvent = events.at(-1) ?? null;
const normalized = normalizeStatus(lastEvent?.status ?? lastEvent?.type);
if (lastEvent?.terminal === true) {
if (normalized === "observed" || normalized === "event" || normalized === "unknown") return "completed";
return normalized;
}
if (TERMINAL_STATUSES.has(normalized) || RUNNING_STATUSES.has(normalized)) return normalized;
if (["failed", "error", "timeout"].includes(normalized)) return normalized;
return "running";
}
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";
}