216 lines
8.1 KiB
TypeScript
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;
|
|
}
|