Files
pikasTech-HWLAB/internal/cloud/workbench-realtime-authority.ts
T

216 lines
8.1 KiB
TypeScript

/*
* SPEC: PJ2026-010401080313 Workbench实时权威 draft-2026-07-08-p0-workbench-realtime-authority-v2.
* Responsibility: Workbench realtime authority sync/replay contract helpers. REST detail routes remain detail/history only.
*/
import { safeSessionId, safeTraceId, sendJson } from "./server-http-utils.ts";
import { createWorkbenchRuntimeClient } from "./workbench-runtime-client.ts";
const SYNC_CONTRACT_VERSION = "workbench-sync-v1";
const REALTIME_AUTHORITY_VERSION = "workbench-realtime-authority-v2";
const DEFAULT_SYNC_LIMIT = 100;
const MAX_SYNC_LIMIT = 500;
const SYNC_FACT_FAMILIES = Object.freeze(["sessions", "messages", "parts", "turns", "traceEvents", "checkpoints"]);
export async function handleWorkbenchSyncHttp(request, response, url, options = {}, actor = null) {
if (request.method !== "GET") {
sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "GET required." } });
return;
}
const sessionId = safeSessionId(url.searchParams.get("sessionId") ?? url.searchParams.get("includeSessionId"));
const traceId = safeTraceId(url.searchParams.get("traceId"));
if (!sessionId && !traceId) {
sendJson(response, 400, {
ok: false,
error: {
code: "workbench_sync_scope_required",
message: "Workbench sync requires sessionId or traceId.",
valuesRedacted: true
}
});
return;
}
const runtime = workbenchRealtimeRuntime(request, options);
if (typeof runtime?.queryWorkbenchFacts !== "function" || typeof runtime?.readWorkbenchProjectionOutbox !== "function") {
sendJson(response, 503, {
ok: false,
error: {
code: "workbench_sync_runtime_unconfigured",
message: "Workbench sync requires durable facts and outbox readers.",
valuesRedacted: true
}
});
return;
}
try {
const since = realtimeSinceCursor(request, url);
const limit = boundedSyncLimit(url.searchParams.get("limit"));
const outboxQuery = { afterSeq: since, limit };
if (sessionId) outboxQuery.sessionId = sessionId;
else outboxQuery.traceId = traceId;
const rows = await runtime.readWorkbenchProjectionOutbox(outboxQuery);
const events = rows.map(workbenchRealtimeEntityDelta);
const latestOutboxSeq = events.reduce((max, event) => Math.max(max, Number(event.cursor?.outboxSeq ?? 0)), since);
const facts = await runtime.queryWorkbenchFacts({
families: SYNC_FACT_FAMILIES,
...(sessionId ? { sessionId } : { traceId }),
limit,
actor: actor ? { id: actor.id, role: actor.role ?? "user", valuesRedacted: true } : undefined
});
if (facts?.error) throw facts.error;
const delta = normalizeSyncFacts(facts?.facts);
sendJson(response, 200, {
ok: true,
status: "succeeded",
contractVersion: SYNC_CONTRACT_VERSION,
realtimeAuthority: REALTIME_AUTHORITY_VERSION,
scope: {
kind: sessionId ? "session" : "trace",
sessionId,
traceId,
since
},
cursor: {
outboxSeq: latestOutboxSeq,
since,
hasMore: rows.length >= limit
},
events,
delta,
families: Object.fromEntries(Object.entries(delta).map(([family, values]) => [family, values.length])),
authority: {
syncReplay: true,
sseEquivalent: true,
detailRoutes: "detail-history-only",
automaticRepairEndpoint: "/v1/workbench/sync",
valuesRedacted: true
},
valuesRedacted: true,
secretMaterialStored: false
});
} catch (error) {
sendJson(response, 503, {
ok: false,
error: {
code: error?.code ?? "workbench_sync_failed",
message: error?.message ?? "Workbench sync failed.",
retryable: error?.data?.retryable !== false,
valuesRedacted: true
}
});
}
}
export function workbenchRealtimeEntityDelta(row = {}) {
const commitType = textValue(row.commitType) || "event";
const projectionRevision = nonNegativeInteger(row.projectionRevision ?? row.projectedSeq ?? row.outboxSeq);
const family = workbenchRealtimeEntityFamily(row, commitType);
const id = workbenchRealtimeEntityId(row, family);
const outboxSeq = nonNegativeInteger(row.outboxSeq);
const projectedSeq = nonNegativeInteger(row.projectedSeq);
return {
type: "workbench.entity.delta",
family,
id,
entity: {
family,
id,
version: projectionRevision,
entityVersion: projectionRevision
},
cursor: {
outboxSeq,
traceSeq: projectedSeq
},
sessionId: safeSessionId(row.sessionId) ?? null,
turnId: textValue(row.turnId) || null,
traceId: safeTraceId(row.traceId) ?? null,
messageId: textValue(row.messageId) || null,
commitType,
eventSeq: row.eventSeq === null || row.eventSeq === undefined ? null : nonNegativeInteger(row.eventSeq),
aggregateId: textValue(row.aggregateId) || null,
aggregateSeq: nonNegativeInteger(row.aggregateSeq),
projectionRevision,
entityVersion: projectionRevision,
terminal: row.terminal === true,
sealed: row.sealed === true,
serverCommittedAt: textValue(row.createdAt) || null,
payload: redactedPayload(row.payload),
valuesRedacted: true
};
}
function workbenchRealtimeRuntime(request, options = {}) {
return options.workbenchRuntime
?? options.runtimeStore
?? createWorkbenchRuntimeClient({ env: options.env ?? process.env, fetch: options.fetch, logger: options.logger, traceparent: request?.hwlabHttpRequestContext?.traceparent });
}
function realtimeSinceCursor(request, url) {
for (const value of [url.searchParams.get("since"), url.searchParams.get("afterSeq"), url.searchParams.get("afterOutboxSeq"), request?.headers?.["last-event-id"]]) {
const parsed = nonNegativeInteger(value);
if (parsed > 0) return parsed;
}
return 0;
}
function boundedSyncLimit(value) {
const parsed = Number.parseInt(String(value ?? ""), 10);
if (!Number.isInteger(parsed) || parsed <= 0) return DEFAULT_SYNC_LIMIT;
return Math.min(Math.max(parsed, 1), MAX_SYNC_LIMIT);
}
function normalizeSyncFacts(facts = {}) {
return {
sessions: factArray(facts.sessions),
messages: factArray(facts.messages),
parts: factArray(facts.parts),
turns: factArray(facts.turns),
traceEvents: factArray(facts.traceEvents).map((event) => ({
...event,
detailProjection: true,
authority: "trace-detail-only"
})),
checkpoints: factArray(facts.checkpoints)
};
}
function workbenchRealtimeEntityFamily(row = {}, commitType = "event") {
if (commitType === "terminal" || row.terminal === true || row.sealed === true) return "turns";
if (commitType === "message" || row.messageId) return "messages";
if (row.traceId) return "traceEvents";
if (row.sessionId) return "sessions";
return "diagnostics";
}
function workbenchRealtimeEntityId(row = {}, family = "diagnostics") {
if (family === "messages") return textValue(row.messageId) || textValue(row.turnId) || textValue(row.traceId) || `outbox:${nonNegativeInteger(row.outboxSeq)}`;
if (family === "turns") return textValue(row.turnId) || textValue(row.traceId) || `outbox:${nonNegativeInteger(row.outboxSeq)}`;
if (family === "traceEvents") return textValue(row.sourceEventId) || [textValue(row.traceId), nonNegativeInteger(row.projectedSeq)].filter(Boolean).join(":") || `outbox:${nonNegativeInteger(row.outboxSeq)}`;
if (family === "sessions") return textValue(row.sessionId) || `outbox:${nonNegativeInteger(row.outboxSeq)}`;
return textValue(row.aggregateId) || `outbox:${nonNegativeInteger(row.outboxSeq)}`;
}
function redactedPayload(payload) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
return {
kind: textValue(payload.kind ?? payload.type ?? payload.eventType) || null,
status: textValue(payload.status) || null,
valuesRedacted: true
};
}
function factArray(value) {
return Array.isArray(value) ? value : [];
}
function textValue(value) {
const text = typeof value === "string" ? value.trim() : value === null || value === undefined ? "" : String(value).trim();
return text || "";
}
function nonNegativeInteger(value) {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
}