410 lines
16 KiB
TypeScript
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()));
|
|
}
|