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

410 lines
16 KiB
TypeScript

// SPEC: PJ2026-010401080313 Workbench实时权威 draft-2026-07-08-p0-workbench-realtime-authority-v2.
// Responsibility: P1 backend events/sync contract tests; no browser, provider, Kubernetes, or live DB dependency.
import assert from "node:assert/strict";
import { test } from "bun:test";
import { createCloudApiServer } from "./server.ts";
const ACTOR = { id: "usr_workbench_realtime_authority", username: "reader", displayName: "Reader", role: "user", status: "active" };
const PROJECTION_REALTIME_ENV = Object.freeze({
HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "false",
HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "false",
HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED: "false",
HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED: "false",
HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "true"
});
const LIVE_REALTIME_ENV = Object.freeze({
HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "true",
HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "true",
HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED: "false",
HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED: "false",
HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED: "false"
});
test("workbench sync fails closed before projection storage in live Kafka capability", async () => {
let syncReads = 0;
const server = createCloudApiServer({
accessController: createAccessController(),
workbenchRuntime: {
async readAtomicWorkbenchProjectionSync() {
syncReads += 1;
throw new Error("live Kafka sync must not reach projection storage");
}
},
kafkaEventBridge: {
started: true,
capabilities: {
directPublish: true,
liveKafkaSse: true,
transactionalProjector: false,
projectionOutboxRelay: false,
projectionRealtime: false
},
ready: Promise.resolve(),
subscribeLiveHwlabEvents() { return () => {}; },
async stop() {}
},
env: { ...LIVE_REALTIME_ENV }
});
await listen(server);
try {
const { port } = server.address();
const response = await getJson(port, "/v1/workbench/sync?sessionId=ses_live_only&since=0");
assert.equal(response.status, 503);
assert.equal(response.body.error.code, "workbench_projection_realtime_disabled");
assert.equal(response.body.error.capabilities.liveKafkaSse, true);
assert.equal(syncReads, 0);
} finally {
await close(server);
}
});
test("workbench sync and SSE read the same atomic projection outbox", async () => {
const sessionId = "ses_realtime_authority_p1";
const traceId = "trc_realtime_authority_p1";
const outboxRows = [{
outboxSeq: 12,
eventSeq: 44,
aggregateId: sessionId,
aggregateSeq: 5,
projectionRevision: 42,
traceId,
sessionId,
turnId: traceId,
messageId: "msg_realtime_authority_agent",
projectedSeq: 8,
sourceSeq: 8,
sourceEventId: "src_realtime_authority_terminal",
commitType: "terminal",
terminal: true,
sealed: true,
createdAt: "2026-07-08T12:20:00.000Z",
entityFamily: "turns",
entityId: traceId,
payload: {
family: "turns",
fact: {
turnId: traceId,
sessionId,
traceId,
messageId: "msg_realtime_authority_agent",
status: "completed",
projectedSeq: 8,
terminal: true,
sealed: true,
finalResponse: { text: "redacted final", status: "completed", traceId }
},
valuesRedacted: true
}
}];
const outboxQueries = [];
const runtime = createRuntime({ facts: durableFacts({ sessionId, traceId }), outboxRows, outboxQueries });
const server = createCloudApiServer({
accessController: createAccessController(),
workbenchRuntime: runtime,
env: {
...PROJECTION_REALTIME_ENV,
HWLAB_WORKBENCH_SSE_HEARTBEAT_MS: "60000",
HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE: "100"
}
});
await listen(server);
try {
const { port } = server.address();
const sync = await getJson(port, `/v1/workbench/sync?sessionId=${encodeURIComponent(sessionId)}&since=10&limit=10`);
assert.equal(sync.status, 200);
assert.equal(sync.body.contractVersion, "workbench-sync-v1");
assert.equal(sync.body.realtimeAuthority, "workbench-realtime-authority-v2");
assert.equal(sync.body.cursor.outboxSeq, 12);
assert.equal(sync.body.events.length, 1);
assert.equal(sync.body.events[0].family, "turns");
assert.equal(sync.body.events[0].entity.version, 42);
assert.equal(sync.body.events[0].terminal, true);
assert.equal(sync.body.events[0].sealed, true);
const events = await getSseEvents(port, `/v1/workbench/events?sessionId=${encodeURIComponent(sessionId)}&traceId=${encodeURIComponent(traceId)}&afterSeq=10`, 2);
assert.deepEqual(events.map((event) => event.event), ["workbench.connected", "workbench.turn.snapshot"]);
assert.equal(events[1].data.realtimeSource, "projection-outbox");
assert.equal(events[1].data.realtimeAuthority, "workbench-realtime-authority-v2");
assert.equal(events[1].data.entity.family, "turns");
assert.equal(events[1].data.entity.version, 42);
assert.equal(events[1].data.turn.finalResponse.text, "redacted final");
assert.deepEqual(outboxQueries.map((query) => query.afterOutboxSeq), [10, 10]);
assert.equal(outboxQueries.every((query) => query.sessionId === sessionId), true);
assert.deepEqual(outboxQueries.at(-1).actor, { id: ACTOR.id, role: ACTOR.role });
} finally {
await close(server);
}
});
test("workbench sync delta includes all authority families and marks trace rows detail-only", async () => {
const sessionId = "ses_realtime_authority_delta";
const traceId = "trc_realtime_authority_delta";
const runtime = createRuntime({
facts: durableFacts({
sessionId,
traceId,
finalText: "sealed backend final",
traceEventText: "trace detail progress"
}),
outboxRows: []
});
const server = createCloudApiServer({ accessController: createAccessController(), workbenchRuntime: runtime, env: { ...PROJECTION_REALTIME_ENV } });
await listen(server);
try {
const { port } = server.address();
const sync = await getJson(port, `/v1/workbench/sync?sessionId=${encodeURIComponent(sessionId)}&since=0`);
assert.equal(sync.status, 200);
assert.equal(sync.body.authority.syncReplay, true);
assert.equal(sync.body.authority.sseEquivalent, true);
assert.equal(sync.body.authority.detailRoutes, "detail-history-only");
assert.equal(sync.body.families.sessions, 1);
assert.equal(sync.body.families.messages, 2);
assert.equal(sync.body.families.parts, 2);
assert.equal(sync.body.families.turns, 1);
assert.equal(sync.body.families.traceEvents, 1);
assert.equal(sync.body.families.checkpoints, 1);
assert.equal(sync.body.delta.turns[0].finalResponse.text, "sealed backend final");
assert.equal(sync.body.delta.traceEvents[0].detailProjection, true);
assert.equal(sync.body.delta.traceEvents[0].authority, "trace-detail-only");
assert.equal(JSON.stringify(sync.body.delta.traceEvents[0]).includes("sealed backend final"), false);
} finally {
await close(server);
}
});
test("workbench sync preserves session and trace scope for narrow replay", async () => {
const sessionId = "ses_realtime_authority_narrow";
const traceId = "trc_realtime_authority_narrow";
const outboxQueries = [];
const runtime = createRuntime({ facts: durableFacts({ sessionId, traceId }), outboxQueries });
const server = createCloudApiServer({ accessController: createAccessController(), workbenchRuntime: runtime, env: { ...PROJECTION_REALTIME_ENV } });
await listen(server);
try {
const { port } = server.address();
const sync = await getJson(port, `/v1/workbench/sync?sessionId=${encodeURIComponent(sessionId)}&traceId=${encodeURIComponent(traceId)}&since=7`);
assert.equal(sync.status, 200);
assert.deepEqual(sync.body.scope, { kind: "trace", sessionId, traceId, since: 7 });
assert.equal(outboxQueries.length, 1);
assert.equal(outboxQueries[0].sessionId, sessionId);
assert.equal(outboxQueries[0].traceId, traceId);
} finally {
await close(server);
}
});
test("workbench trace event detail route declares detail-only authority", async () => {
const sessionId = "ses_realtime_authority_detail";
const traceId = "trc_realtime_authority_detail";
const runtime = createRuntime({
facts: durableFacts({ sessionId, traceId, finalText: "sealed final", traceEventText: "detail row" }),
outboxRows: []
});
const server = createCloudApiServer({ accessController: createAccessController(), workbenchRuntime: runtime, env: { ...PROJECTION_REALTIME_ENV } });
await listen(server);
try {
const { port } = server.address();
const detail = await getJson(port, `/v1/workbench/traces/${encodeURIComponent(traceId)}/events?limit=10`);
assert.equal(detail.status, 200);
assert.equal(detail.body.detailProjection, true);
assert.equal(detail.body.authority, "trace-detail-only");
assert.equal(detail.body.realtimeAuthority, "workbench-realtime-authority-v2");
assert.equal(detail.body.events.length, 1);
assert.equal(detail.body.events[0].authority, "trace-detail-only");
assert.equal(detail.body.finalResponse, undefined);
} finally {
await close(server);
}
});
test("workbench sync rejects unscoped automatic repair requests", async () => {
const server = createCloudApiServer({ accessController: createAccessController(), workbenchRuntime: createRuntime({ facts: durableFacts({}) }), env: { ...PROJECTION_REALTIME_ENV } });
await listen(server);
try {
const { port } = server.address();
const response = await getJson(port, "/v1/workbench/sync?since=0");
assert.equal(response.status, 400);
assert.equal(response.body.error.code, "workbench_sync_scope_required");
} finally {
await close(server);
}
});
function createAccessController() {
return {
async ensureBootstrap() {},
async authenticate() {
return { ok: true, actor: ACTOR, session: { id: "uss_workbench_realtime_authority" } };
}
};
}
function createRuntime({ facts, outboxRows = [], outboxQueries = [] }) {
return {
async readAtomicWorkbenchProjectionSync(params = {}) {
outboxQueries.push({ ...params });
const after = Number(params.afterOutboxSeq ?? params.afterSeq ?? 0);
const events = outboxRows.filter((row) => Number(row.outboxSeq) > after);
const cutoffOutboxSeq = outboxRows.reduce((max, row) => Math.max(max, Number(row.outboxSeq) || 0), 0);
return {
facts: filterFacts(facts, params),
events,
cutoffOutboxSeq,
cursorOutboxSeq: cutoffOutboxSeq,
hasMore: false,
valuesRedacted: true
};
},
async queryWorkbenchFacts(params = {}) {
return {
facts: filterFacts(facts, params),
count: Object.values(facts).reduce((sum, rows) => sum + rows.length, 0),
persistence: { adapter: "test-realtime-authority", durable: true }
};
}
};
}
function durableFacts({ sessionId = "ses_realtime_authority", traceId = "trc_realtime_authority", finalText = "sealed final", traceEventText = "detail progress" } = {}) {
const now = "2026-07-08T12:20:00.000Z";
return {
sessions: [{
sessionId,
ownerUserId: ACTOR.id,
projectId: "prj_realtime_authority",
conversationId: "cnv_realtime_authority",
threadId: "thread-realtime-authority",
status: "completed",
lastTraceId: traceId,
projectedSeq: 42,
terminal: true,
sealed: true,
updatedAt: now,
valuesRedacted: true
}],
messages: [
{ messageId: "msg_realtime_authority_user", sessionId, turnId: traceId, traceId, role: "user", status: "sent", projectedSeq: 1, text: "hi", updatedAt: now, valuesRedacted: true },
{ messageId: "msg_realtime_authority_agent", sessionId, turnId: traceId, traceId, role: "agent", status: "completed", projectedSeq: 42, terminal: true, sealed: true, text: finalText, updatedAt: now, valuesRedacted: true }
],
parts: [
{ partId: "prt_realtime_authority_user", messageId: "msg_realtime_authority_user", sessionId, turnId: traceId, traceId, partIndex: 0, partType: "text", status: "sent", text: "hi", projectedSeq: 1, updatedAt: now, valuesRedacted: true },
{ partId: "prt_realtime_authority_final", messageId: "msg_realtime_authority_agent", sessionId, turnId: traceId, traceId, partIndex: 0, partType: "final_response", status: "completed", text: finalText, projectedSeq: 42, terminal: true, sealed: true, updatedAt: now, valuesRedacted: true }
],
turns: [{
turnId: traceId,
sessionId,
traceId,
messageId: "msg_realtime_authority_agent",
status: "completed",
projectedSeq: 42,
terminal: true,
sealed: true,
finalResponse: { text: finalText, status: "completed", traceId, valuesPrinted: false },
timing: { startedAt: now, lastEventAt: now, finishedAt: now, durationMs: 0, valuesRedacted: true },
updatedAt: now,
valuesRedacted: true
}],
traceEvents: [{
id: "wte_realtime_authority_detail",
traceId,
sessionId,
turnId: traceId,
messageId: "msg_realtime_authority_agent",
projectedSeq: 42,
sourceSeq: 42,
sourceEventId: "src_realtime_authority_detail",
eventType: "assistant_message",
status: "completed",
message: traceEventText,
terminal: true,
sealed: true,
occurredAt: now,
updatedAt: now,
valuesRedacted: true
}],
checkpoints: [{
traceId,
sessionId,
turnId: traceId,
projectedSeq: 42,
sourceSeq: 42,
projectionStatus: "caught_up",
projectionHealth: "healthy",
terminal: true,
sealed: true,
updatedAt: now,
valuesRedacted: true
}]
};
}
function filterFacts(facts, params = {}) {
const families = new Set(Array.isArray(params.families) ? params.families : ["sessions", "messages", "parts", "turns", "traceEvents", "checkpoints"]);
const sessionId = params.sessionId;
const traceId = params.traceId;
return {
sessions: families.has("sessions") ? facts.sessions.filter((record) => (!sessionId || record.sessionId === sessionId) && (!traceId || record.lastTraceId === traceId)) : [],
messages: families.has("messages") ? facts.messages.filter((record) => (!sessionId || record.sessionId === sessionId) && (!traceId || record.traceId === traceId)) : [],
parts: families.has("parts") ? facts.parts.filter((record) => (!sessionId || record.sessionId === sessionId) && (!traceId || record.traceId === traceId)) : [],
turns: families.has("turns") ? facts.turns.filter((record) => (!sessionId || record.sessionId === sessionId) && (!traceId || record.traceId === traceId)) : [],
traceEvents: families.has("traceEvents") ? facts.traceEvents.filter((record) => (!sessionId || record.sessionId === sessionId) && (!traceId || record.traceId === traceId)) : [],
checkpoints: families.has("checkpoints") ? facts.checkpoints.filter((record) => (!sessionId || record.sessionId === sessionId) && (!traceId || record.traceId === traceId)) : []
};
}
async function getJson(port, path) {
const response = await fetch(`http://127.0.0.1:${port}${path}`);
return { status: response.status, body: await response.json() };
}
async function getSseEvents(port, path, count) {
const controller = new AbortController();
const response = await fetch(`http://127.0.0.1:${port}${path}`, { signal: controller.signal });
assert.equal(response.status, 200);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const events = [];
try {
while (events.length < count) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
for (;;) {
const index = buffer.indexOf("\n\n");
if (index < 0) break;
const block = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
events.push(parseSseBlock(block));
if (events.length >= count) break;
}
}
} finally {
controller.abort();
}
return events;
}
function parseSseBlock(block) {
const lines = block.split(/\n/u);
const event = lines.find((line) => line.startsWith("event:"))?.slice("event:".length).trim() ?? "message";
const id = lines.find((line) => line.startsWith("id:"))?.slice("id:".length).trim() ?? null;
const dataLine = lines.find((line) => line.startsWith("data:"))?.slice("data:".length).trim() ?? "{}";
return { event, id, data: JSON.parse(dataLine) };
}
function listen(server) {
return new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
}
function close(server) {
return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}