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

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;
}