fix(workbench): replay realtime outbox by after seq (#2068)

This commit is contained in:
Lyon
2026-06-24 22:45:17 +08:00
committed by GitHub
parent 66cdf0b8f7
commit c604dd2236
4 changed files with 102 additions and 4 deletions
+51 -1
View File
@@ -1570,6 +1570,55 @@ test("workbench realtime stream accepts session authority without project or wor
}
});
test("workbench realtime stream replays durable outbox after requested seq", async () => {
const traceId = "trc_workbench_realtime_after_seq";
const session = {
id: "ses_workbench_realtime_after_seq",
projectId: "prj_hwpod_workbench",
agentId: "hwlab-code-agent",
status: "running",
ownerUserId: ACTOR.id,
conversationId: "cnv_workbench_realtime_after_seq",
threadId: "thread-workbench-realtime-after-seq",
lastTraceId: traceId,
updatedAt: "2026-06-24T14:00:00.000Z",
session: { sessionStatus: "running", lastTraceId: traceId }
};
const outboxQueries = [];
const runtimeStore = {
async readWorkbenchProjectionOutbox(params = {}) {
outboxQueries.push({ ...params });
return [
{ outboxSeq: 11, projectedSeq: 7, traceId, sessionId: session.id, turnId: traceId, commitType: "event", terminal: false, sealed: false, createdAt: "2026-06-24T14:00:01.000Z" }
];
}
};
const accessController = {
store: {
async getAgentSession(sessionId) { return sessionId === session.id ? session : null; },
async getAgentSessionByTraceId(requestTraceId) { return requestTraceId === traceId ? session : null; }
},
async ensureBootstrap() {},
async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; }
};
const server = createCloudApiServer({ accessController, runtimeStore, env: { HWLAB_WORKBENCH_SSE_HEARTBEAT_MS: "60000" } });
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const events = await getSseEvents(port, `/v1/workbench/events?sessionId=${encodeURIComponent(session.id)}&afterSeq=10`, 2);
assert.deepEqual(events.map((event) => event.event), ["workbench.connected", "workbench.trace.event"]);
assert.equal(events[0].id, "10");
assert.equal(events[1].id, "11");
assert.equal(events[1].data.cursor.outboxSeq, 11);
assert.equal(events[1].data.cursor.traceSeq, 7);
assert.equal(outboxQueries[0].afterSeq, 10);
assert.equal(outboxQueries[0].sessionId, session.id);
} finally {
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
test("workbench read model exposes runtime trace projection query failures as projection blockers", async () => {
const traceStore = createCodeAgentTraceStore();
const results = createCodeAgentChatResultStore();
@@ -2021,6 +2070,7 @@ async function getSseEvents(port, path, count) {
function parseSseBlock(block) {
const lines = block.split("\n");
const event = lines.find((line) => line.startsWith("event:"))?.slice(6).trim() ?? "message";
const id = lines.find((line) => line.startsWith("id:"))?.slice(3).trim() ?? null;
const data = lines.filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim()).join("\n");
return { event, data: JSON.parse(data) };
return { event, id, data: JSON.parse(data) };
}
+21 -2
View File
@@ -129,6 +129,7 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
const readModel = createWorkbenchReadModel(options, auth.actor);
const requestedAfterSeq = workbenchRealtimeAfterSeq(request, url);
let closed = false;
let realtimeCloseReason = "client_close";
let realtimeCloseSignal = null;
@@ -152,7 +153,9 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option
const startedAt = nowMs();
const eventCreatedAt = realtimeEventCreatedAt(payload);
const traceSeq = realtimeTraceSeq(payload);
const eventId = realtimeEventId(payload);
response.write(`event: ${name}\n`);
if (eventId) response.write(`id: ${eventId}\n`);
response.write(`data: ${JSON.stringify({ contractVersion: "workbench-events-v1", serverSentAt: new Date().toISOString(), eventCreatedAt, traceSeq, ...payload })}\n\n`);
perf?.recordPhase({ phase: "sse_write", durationMs: nowMs() - startedAt, outcome: "ok" });
};
@@ -192,6 +195,7 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option
type: "connected",
status: "connected",
heartbeatMs,
cursor: { outboxSeq: requestedAfterSeq },
filters: {
sessionId: requestedSessionId,
traceId: requestedTraceId
@@ -216,12 +220,12 @@ export async function handleWorkbenchRealtimeHttp(request, response, url, option
outboxMode: typeof (options.runtimeStore ?? null)?.readWorkbenchProjectionOutbox === "function"
});
emitWorkbenchRealtimeAcceptedOtelSpan(request, options.env);
if (activeTraceId) {
if (activeTraceId && requestedAfterSeq <= 0) {
await (perf ? perf.measure("workbench_initial_trace", () => writeTraceRealtimeSnapshotSafe({ writeEvent, options, actor: auth.actor, traceId: activeTraceId, reason: "initial" })) : writeTraceRealtimeSnapshotSafe({ writeEvent, options, actor: auth.actor, traceId: activeTraceId, reason: "initial" }));
}
const runtimeStore = options.runtimeStore ?? null;
const outboxPollMs = parsePositiveInteger(options.env?.HWLAB_WORKBENCH_SSE_OUTBOX_POLL_MS, 500);
let outboxCursor = 0;
let outboxCursor = requestedAfterSeq;
const outboxTraceSnapshots = new Set(activeTraceId ? [activeTraceId] : []);
if (typeof runtimeStore?.readWorkbenchProjectionOutbox === "function" && (streamSessionId || activeTraceId)) {
const pollOutbox = async () => {
@@ -530,6 +534,21 @@ function realtimeTraceSeq(payload) {
return Number.isFinite(seq) && seq >= 0 ? seq : null;
}
function realtimeEventId(payload) {
const raw = payload?.cursor?.outboxSeq ?? payload?.outboxSeq ?? payload?.id;
const text = textValue(raw);
if (!text || /[\r\n]/u.test(text)) return null;
return text.slice(0, 128);
}
function workbenchRealtimeAfterSeq(request, url) {
for (const value of [url.searchParams.get("afterSeq"), url.searchParams.get("afterOutboxSeq"), request?.headers?.["last-event-id"]]) {
const parsed = nonNegativeInteger(value, 0);
if (parsed > 0) return parsed;
}
return 0;
}
function nowMs() {
if (typeof performance !== "undefined" && typeof performance.now === "function") return performance.now();
return Date.now();
@@ -34,13 +34,15 @@ export interface WorkbenchRealtimeEvent {
reason?: string | null;
error?: { code?: string; message?: string; [key: string]: unknown } | null;
traceSeq?: number | null;
cursor?: { traceSeq?: number | null; [key: string]: unknown };
outboxSeq?: number | null;
cursor?: { traceSeq?: number | null; outboxSeq?: number | null; [key: string]: unknown };
[key: string]: unknown;
}
export interface WorkbenchEventStreamOptions {
sessionId?: string | null;
traceId?: string | null;
afterSeq?: number | null;
onEvent: (event: WorkbenchRealtimeEvent, eventName: string) => void;
onOpen?: () => void;
onError?: (event: Event) => void;
@@ -66,6 +68,7 @@ export function connectWorkbenchEvents(options: WorkbenchEventStreamOptions): Wo
const params = new URLSearchParams();
appendParam(params, "sessionId", options.sessionId);
appendParam(params, "traceId", options.traceId);
appendNumberParam(params, "afterSeq", options.afterSeq);
const eventRoute = `/v1/workbench/events?${params.toString()}`;
const connectStartedAt = typeof performance === "undefined" ? Date.now() : performance.now();
recordWorkbenchSseLifecycle({ state: "connect", route: eventRoute, sessionId: options.sessionId, traceId: options.traceId });
@@ -105,6 +108,12 @@ function appendParam(params: URLSearchParams, key: string, value: string | null
if (text) params.set(key, text);
}
function appendNumberParam(params: URLSearchParams, key: string, value: number | null | undefined): void {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return;
params.set(key, String(Math.trunc(parsed)));
}
function parseRealtimeEvent(raw: string): WorkbenchRealtimeEvent | null {
try {
const value = JSON.parse(raw);
@@ -79,6 +79,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const realtimeSessionRefreshInFlight = new Set<string>();
let realtimeStream: WorkbenchEventStream | null = null;
let realtimeKey = "";
const realtimeOutboxSeqByKey = new Map<string, number>();
const sessionListRefreshInFlight = new Map<string, Promise<void>>();
const sessionListRefreshTimers = new Map<string, number>();
const sessionListLastRefreshAtByKey = new Map<string, number>();
@@ -1082,9 +1083,11 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
stopRealtime();
realtimeKey = key;
if (!sessionId && !traceId) return;
const afterSeq = realtimeOutboxSeqByKey.get(key) ?? null;
realtimeStream = connectWorkbenchEvents({
sessionId,
traceId,
afterSeq,
onOpen: () => undefined,
onError: () => undefined,
onEvent: (event, eventName) => applyRealtimeEvent(event, eventName)
@@ -1108,6 +1111,22 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
return normalizeWorkbenchSessionId(firstNonEmptyString(event.sessionId, turn?.sessionId, snapshot?.sessionId, traceEvent?.sessionId));
}
function rememberRealtimeOutboxSeq(event: WorkbenchRealtimeEvent): void {
const outboxSeq = realtimeOutboxSeq(event);
if (outboxSeq === null) return;
const sessionId = realtimeEventSessionId(event) ?? selectedSessionId.value ?? null;
const traceId = firstNonEmptyString(event.traceId, event.event?.traceId, event.snapshot?.traceId, currentRequest.value?.traceId, activeTraceIdFromMessages(messages.value, turnStatusAuthority.value));
const key = [sessionId ?? "", traceId ?? ""].join("|");
if (!key.trim()) return;
const previous = realtimeOutboxSeqByKey.get(key) ?? 0;
if (outboxSeq > previous) realtimeOutboxSeqByKey.set(key, outboxSeq);
}
function realtimeOutboxSeq(event: WorkbenchRealtimeEvent): number | null {
const parsed = Number(event.cursor?.outboxSeq ?? event.outboxSeq);
return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : null;
}
function traceResultSessionId(result: AgentChatResultResponse | TraceSnapshot | Record<string, unknown> | null | undefined): string | null {
const value = recordValue(result);
const runnerTrace = recordValue(value?.runnerTrace);
@@ -1171,6 +1190,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
function applyRealtimeEvent(event: WorkbenchRealtimeEvent, eventName: string): void {
recordActivity(event.type ? `realtime:${event.type}` : `realtime:${eventName}`);
rememberRealtimeOutboxSeq(event);
if (event.type === "trace.snapshot") {
applyRealtimeTraceSnapshot(event.traceId, event.snapshot);
return;