163 lines
5.7 KiB
TypeScript
163 lines
5.7 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";
|
|
import { projectionOutboxRealtimeEvents } from "./workbench-projection-outbox-events.ts";
|
|
import { workbenchRealtimeCapabilities } from "./workbench-realtime-capabilities.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;
|
|
|
|
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 capabilities = workbenchRealtimeCapabilities(options.env ?? process.env);
|
|
if (!capabilities.projectionRealtime) {
|
|
sendJson(response, 503, {
|
|
ok: false,
|
|
error: {
|
|
code: "workbench_projection_realtime_disabled",
|
|
message: "Workbench projection sync/replay capability is disabled.",
|
|
valuesRedacted: true
|
|
}
|
|
});
|
|
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?.readAtomicWorkbenchProjectionSync !== "function") {
|
|
sendJson(response, 503, {
|
|
ok: false,
|
|
error: {
|
|
code: "workbench_sync_runtime_unconfigured",
|
|
message: "Workbench sync requires one atomic durable projection snapshot reader.",
|
|
valuesRedacted: true
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const since = realtimeSinceCursor(request, url);
|
|
const limit = boundedSyncLimit(url.searchParams.get("limit"));
|
|
const snapshot = await runtime.readAtomicWorkbenchProjectionSync({
|
|
afterOutboxSeq: since,
|
|
afterSeq: since,
|
|
limit,
|
|
...(sessionId ? { sessionId } : {}),
|
|
...(traceId ? { traceId } : {}),
|
|
actor: actor ? { id: actor.id, role: actor.role ?? "user" } : undefined
|
|
});
|
|
const rows = Array.isArray(snapshot?.events) ? snapshot.events : [];
|
|
const events = projectionOutboxRealtimeEvents({ events: rows, facts: {} }).map((item) => item.payload);
|
|
const latestOutboxSeq = nonNegativeInteger(snapshot?.cursorOutboxSeq);
|
|
const delta = normalizeSyncFacts(snapshot?.facts);
|
|
sendJson(response, 200, {
|
|
ok: true,
|
|
status: "succeeded",
|
|
contractVersion: SYNC_CONTRACT_VERSION,
|
|
realtimeAuthority: REALTIME_AUTHORITY_VERSION,
|
|
scope: {
|
|
kind: traceId ? "trace" : "session",
|
|
sessionId,
|
|
traceId,
|
|
since
|
|
},
|
|
cursor: {
|
|
outboxSeq: latestOutboxSeq,
|
|
since,
|
|
cutoffOutboxSeq: nonNegativeInteger(snapshot?.cutoffOutboxSeq),
|
|
hasMore: snapshot?.hasMore === true
|
|
},
|
|
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
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function workbenchRealtimeRuntime(request, options = {}) {
|
|
return options.workbenchRuntime
|
|
?? 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 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;
|
|
}
|