fix(workbench): stabilize Kafka replay visibility

This commit is contained in:
root
2026-07-21 13:18:40 +02:00
parent d13209c1be
commit 15b45a6de7
14 changed files with 427 additions and 23 deletions
+13
View File
@@ -379,6 +379,19 @@ test("projects AgentRun fileChange and diff events without dropping paths or uni
assert.deepEqual(fileChange.event.changes, changes); assert.deepEqual(fileChange.event.changes, changes);
assert.equal(fileChange.event.fileCount, 2); assert.equal(fileChange.event.fileCount, 2);
const truncatedFileChange = projectAgentRunKafkaEventToHwlabEvent({
schema: "agentrun.event.v1",
eventType: "agentrun.event.committed",
eventId: "evt_file_change_truncated",
traceId: "trc_file_change",
hwlabSessionId: "ses_file_change",
runId: "run_file_change",
event: { id: "evt_file_change_truncated", seq: 22, type: "tool_call", payload: { method: "item/completed", itemId: "item_large", type: "fileChange", toolName: "fileChange", status: "completed", changes: [{ path: "src/large.ts", kind: "add", additions: 400, deletions: 0, diffTruncated: true }], fileCount: 1, summary: { text: "bounded file change metadata", outputTruncated: true }, outputTruncated: true } }
}, { source: "hwlab-test", sourceTopic: "agentrun.event.debug.v1", sourceOffset: "22" });
assert.equal(truncatedFileChange.event.message, "AgentRun fileChange (1 file)");
assert.deepEqual(truncatedFileChange.event.changes, [{ path: "src/large.ts", kind: "add", additions: 400, deletions: 0, diffTruncated: true }]);
assert.doesNotMatch(truncatedFileChange.event.message, /\[object Object\]/u);
const diff = projectAgentRunKafkaEventToHwlabEvent({ const diff = projectAgentRunKafkaEventToHwlabEvent({
schema: "agentrun.event.v1", schema: "agentrun.event.v1",
eventType: "agentrun.event.committed", eventType: "agentrun.event.committed",
+4 -3
View File
@@ -1632,13 +1632,14 @@ function mapAgentRunSourceEventToHwlabEvent({ input, sourceEvent, payload, run,
} }
if (type === "tool_call") { if (type === "tool_call") {
const toolName = firstText(payload.toolName, payload.name, payload.method) || "tool"; const toolName = firstText(payload.toolName, payload.name, payload.method) || "tool";
const changes = Array.isArray(payload.changes) ? payload.changes : [];
return { return {
...base, ...base,
type: "tool", type: "tool",
eventType: "tool", eventType: "tool",
status: firstText(payload.status) || "running", status: firstText(payload.status) || "running",
label: `agentrun:tool:${toolName}`, label: `agentrun:tool:${toolName}`,
message: textPayload(payload, `AgentRun tool ${toolName}`), message: toolName === "fileChange" ? `AgentRun fileChange (${changes.length} file${changes.length === 1 ? "" : "s"})` : textPayload(payload, `AgentRun tool ${toolName}`),
toolName, toolName,
toolType: firstText(payload.type), toolType: firstText(payload.type),
method: firstText(payload.method), method: firstText(payload.method),
@@ -1654,7 +1655,7 @@ function mapAgentRunSourceEventToHwlabEvent({ input, sourceEvent, payload, run,
commandTruncated: payload.commandTruncated === true, commandTruncated: payload.commandTruncated === true,
outputBytes: integerValue(payload.outputBytes ?? payload.summary?.outputBytes), outputBytes: integerValue(payload.outputBytes ?? payload.summary?.outputBytes),
outputTruncated: payload.outputTruncated === true || payload.summary?.outputTruncated === true, outputTruncated: payload.outputTruncated === true || payload.summary?.outputTruncated === true,
...(Array.isArray(payload.changes) ? { changes: payload.changes, fileCount: integerValue(payload.fileCount) } : {}), ...(changes.length > 0 ? { changes, fileCount: integerValue(payload.fileCount) } : {}),
terminal: false terminal: false
}; };
} }
@@ -1936,7 +1937,7 @@ function kafkaHeaders(projected) {
} }
function textPayload(payload, fallback) { function textPayload(payload, fallback) {
return firstText(payload.text, payload.message, payload.summary, payload.output, payload.errorMessage, payload.reason) || fallback; return firstText(payload.text, payload.message, payload.summary?.text, payload.output, payload.errorMessage, payload.reason) || fallback;
} }
function truthy(value) { function truthy(value) {
+38 -5
View File
@@ -551,15 +551,48 @@ test("fileChange and turn diff render real paths and unified diff as valid Markd
]); ]);
const markdown = renderTraceRowsMarkdown(rows); const markdown = renderTraceRowsMarkdown(rows);
assert.equal(rows.length, 2); assert.equal(rows.length, 2);
assert.match(markdown, /fileChange src\/example\.ts, docs\/\.md/u); assert.match(markdown, /2 · \+2 -1 · src\/example\.ts, docs\/\.md/u);
assert.match(markdown, /path: src\/example\.ts/u); assert.match(markdown, /#### `src\/example\.ts`/u);
assert.match(markdown, /path: docs\/\.md/u); assert.match(markdown, /#### `docs\/\.md`/u);
assert.match(markdown, /\+export const value = 2;/u); assert.match(markdown, /\+export const value = 2;/u);
assert.match(markdown, /turn\/diff\/updated/u); assert.match(markdown, / diff /u);
assert.match(markdown, /\+# /u); assert.match(markdown, /\+# /u);
assert.equal((markdown.match(/<details>/gu) ?? []).length, 2); assert.equal((markdown.match(/<details>/gu) ?? []).length, 2);
assert.equal((markdown.match(/<\/details>/gu) ?? []).length, 2); assert.equal((markdown.match(/<\/details>/gu) ?? []).length, 2);
assert.equal((markdown.match(/```text/gu) ?? []).length, 2); assert.ok(rows.every((row) => row.bodyFormat === "markdown"));
});
test("fileChange rendering uses preserved stats and ignores historical object coercion", () => {
const rows = traceDisplayRows({ eventSource: "hwlab-kafka-sse" }, [
{
sourceSeq: 53,
type: "tool",
eventType: "tool",
status: "completed",
label: "agentrun:tool:fileChange",
toolName: "fileChange",
itemId: "item_large_file_change",
changes: [{ path: "/agentrun-workspace/src/large.ts", kind: "add", additions: 400, deletions: 0, diffTruncated: true }],
message: "bounded file change metadata",
createdAt: "2026-07-21T08:04:30.452Z"
},
{
sourceSeq: 54,
type: "tool",
eventType: "tool",
status: "completed",
label: "agentrun:tool:fileChange",
toolName: "fileChange",
itemId: "item_historical_file_change",
message: "[object Object]",
createdAt: "2026-07-21T08:04:31.452Z"
}
]);
assert.equal(rows[0]?.preview, "src/large.ts · +400 -0");
assert.match(rows[0]?.body ?? "", / · \+400 -0/u);
assert.doesNotMatch(JSON.stringify(rows), /\[object Object\]/u);
assert.equal(rows[1]?.body, null);
assert.equal(rows[1]?.preview, "fileChange");
}); });
test("outer final response suppression follows formal assistant identity instead of equal text", () => { test("outer final response suppression follows formal assistant identity instead of equal text", () => {
+15 -3
View File
@@ -403,13 +403,21 @@ function traceDiffFiles(event: TraceEvent): TraceDiffFile[] {
return changes.map((change) => traceDiffFile( return changes.map((change) => traceDiffFile(
traceDiffPath(nonEmptyString(change.path)), traceDiffPath(nonEmptyString(change.path)),
traceDiffKind(change.kind), traceDiffKind(change.kind),
nonEmptyString(change.diff) ?? "" traceDiffText(change.diff) ?? "",
numberOrNull(change.additions),
numberOrNull(change.deletions)
)); ));
} }
const diff = nonEmptyString(event.diff ?? event.message); const diff = traceDiffText(event.diff ?? event.message);
return diff ? traceUnifiedDiffFiles(diff) : []; return diff ? traceUnifiedDiffFiles(diff) : [];
} }
function traceDiffText(value: unknown): string | null {
if (typeof value !== "string") return null;
const text = value.trim();
return text && text !== "[object Object]" ? value : null;
}
function traceUnifiedDiffFiles(diff: string): TraceDiffFile[] { function traceUnifiedDiffFiles(diff: string): TraceDiffFile[] {
const sections: Array<{ path: string | null; lines: string[] }> = []; const sections: Array<{ path: string | null; lines: string[] }> = [];
let current: { path: string | null; lines: string[] } = { path: null, lines: [] }; let current: { path: string | null; lines: string[] } = { path: null, lines: [] };
@@ -436,7 +444,7 @@ function traceDiffHeaderPath(value: string): string | null {
return traceDiffPath(path.startsWith("b/") ? path.slice(2) : path); return traceDiffPath(path.startsWith("b/") ? path.slice(2) : path);
} }
function traceDiffFile(path: string | null, kind: string | null, diff: string): TraceDiffFile { function traceDiffFile(path: string | null, kind: string | null, diff: string, additionsHint: number | null = null, deletionsHint: number | null = null): TraceDiffFile {
const normalizedDiff = traceNormalizedFileDiff(path, kind, diff); const normalizedDiff = traceNormalizedFileDiff(path, kind, diff);
let additions = 0; let additions = 0;
let deletions = 0; let deletions = 0;
@@ -444,6 +452,10 @@ function traceDiffFile(path: string | null, kind: string | null, diff: string):
if (line.startsWith("+") && !line.startsWith("+++")) additions += 1; if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
if (line.startsWith("-") && !line.startsWith("---")) deletions += 1; if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
} }
if (!normalizedDiff) {
additions = additionsHint ?? additions;
deletions = deletionsHint ?? deletions;
}
return { path, kind, diff: normalizedDiff, additions, deletions }; return { path, kind, diff: normalizedDiff, additions, deletions };
} }
@@ -91,6 +91,57 @@ test("product EventSource batches a burst until the configured scheduler runs",
} }
}); });
test("retention replay reaches connected without animation frames", async () => {
const originalEventSource = globalThis.EventSource;
const originalWindow = globalThis.window;
const delivered: Array<[string, string | null]> = [];
let animationFrameCalls = 0;
FakeProductEventSource.instances = [];
globalThis.EventSource = FakeProductEventSource as unknown as typeof EventSource;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
requestAnimationFrame: () => {
animationFrameCalls += 1;
return 1;
},
cancelAnimationFrame: () => undefined
}
});
try {
const stream = connectWorkbenchEvents({
realtimeCapabilities: { liveKafkaSse: true, kafkaRefreshReplay: true, projectionRealtime: false },
sessionId: "ses_background_replay",
flushMaxItemsPerChunk: 4,
flushMaxChunkMs: 8,
flushYieldMs: 0,
onEvent: (event, eventName) => delivered.push([eventName, event.eventId ?? null])
});
assert.ok(stream);
const source = FakeProductEventSource.instances[0];
for (let index = 0; index < 395; index += 1) {
source.emitRaw("hwlab.event.v1", JSON.stringify({ schema: "hwlab.event.v1", eventId: `evt_${index}` }));
}
source.emitRaw("workbench.connected", JSON.stringify({ type: "connected", filters: { sessionId: "ses_background_replay" } }));
await waitFor(() => delivered.length === 396, 1_000);
assert.equal(animationFrameCalls, 0);
assert.deepEqual(delivered.at(-1), ["workbench.connected", null]);
stream.close();
} finally {
globalThis.EventSource = originalEventSource;
Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
}
});
async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= deadline) throw new Error(`condition did not pass within ${timeoutMs}ms`);
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
class FakeProductEventSource { class FakeProductEventSource {
static instances: FakeProductEventSource[] = []; static instances: FakeProductEventSource[] = [];
onopen: ((event: Event) => void) | null = null; onopen: ((event: Event) => void) | null = null;
@@ -179,6 +179,9 @@ export function connectWorkbenchEvents(options: WorkbenchEventStreamOptions): Wo
close() { close() {
queue.clear("stream-close"); queue.clear("stream-close");
for (const { name, listener } of listeners) source.removeEventListener(name, listener); for (const { name, listener } of listeners) source.removeEventListener(name, listener);
source.onopen = null;
source.onerror = null;
source.onmessage = null;
recordWorkbenchSseLifecycle({ state: "close", route: eventRoute, sessionId: options.sessionId, traceId: options.traceId }); recordWorkbenchSseLifecycle({ state: "close", route: eventRoute, sessionId: options.sessionId, traceId: options.traceId });
source.close(); source.close();
} }
@@ -211,12 +214,23 @@ function enqueueRealtimeFrame(raw: unknown, eventName: string, options: Workbenc
if (decoded.payload) queue.push({ payload: decoded.payload as WorkbenchRealtimeEvent, eventName }); if (decoded.payload) queue.push({ payload: decoded.payload as WorkbenchRealtimeEvent, eventName });
} }
function scheduleRealtimeFlushYield(flush: () => void, yieldMs: number | null | undefined): () => void { export function scheduleRealtimeFlushYield(flush: () => void, yieldMs: number | null | undefined): () => void {
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function" && (!Number.isFinite(Number(yieldMs)) || Number(yieldMs) <= 0)) {
const id = window.requestAnimationFrame(() => flush());
return () => window.cancelAnimationFrame(id);
}
const delay = Math.max(0, Math.trunc(Number(yieldMs ?? 0))); const delay = Math.max(0, Math.trunc(Number(yieldMs ?? 0)));
if (delay === 0 && typeof MessageChannel !== "undefined") {
const channel = new MessageChannel();
let canceled = false;
channel.port1.onmessage = () => {
channel.port1.close();
channel.port2.close();
if (!canceled) flush();
};
channel.port2.postMessage(undefined);
return () => {
canceled = true;
channel.port1.close();
channel.port2.close();
};
}
const id = setTimeout(() => flush(), delay); const id = setTimeout(() => flush(), delay);
return () => clearTimeout(id); return () => clearTimeout(id);
} }
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import { resolveBottomFollowingAfterScroll } from "./useBottomFollowScroll";
test("content growth before alignment does not cancel bottom following", () => {
assert.equal(resolveBottomFollowingAfterScroll({
following: true,
previousScrollTop: 400,
currentScrollTop: 400,
distanceToBottom: 180,
threshold: 24
}), true);
});
test("an actual upward scroll pauses following until the user returns to bottom", () => {
const paused = resolveBottomFollowingAfterScroll({
following: true,
previousScrollTop: 580,
currentScrollTop: 300,
distanceToBottom: 280,
threshold: 24
});
assert.equal(paused, false);
assert.equal(resolveBottomFollowingAfterScroll({
following: paused,
previousScrollTop: 300,
currentScrollTop: 556,
distanceToBottom: 24,
threshold: 24
}), true);
});
@@ -5,12 +5,27 @@ export interface BottomFollowScrollOptions {
initialFollowing?: boolean; initialFollowing?: boolean;
} }
export interface BottomFollowPositionState {
following: boolean;
previousScrollTop: number;
currentScrollTop: number;
distanceToBottom: number;
threshold: number;
}
export function resolveBottomFollowingAfterScroll(state: BottomFollowPositionState): boolean {
if (state.distanceToBottom <= state.threshold) return true;
if (!state.following) return false;
return state.currentScrollTop + 1 >= state.previousScrollTop;
}
export function useBottomFollowScroll<T extends HTMLElement>(containerRef: Ref<T | null>, options: BottomFollowScrollOptions = {}) { export function useBottomFollowScroll<T extends HTMLElement>(containerRef: Ref<T | null>, options: BottomFollowScrollOptions = {}) {
const threshold = options.threshold ?? 24; const threshold = options.threshold ?? 24;
const following = ref(options.initialFollowing ?? true); const following = ref(options.initialFollowing ?? true);
let animationFrame: number | null = null; let animationFrame: number | null = null;
let releaseProgrammaticFrame: number | null = null; let releaseProgrammaticFrame: number | null = null;
let programmaticScroll = false; let programmaticScroll = false;
let lastScrollTop = 0;
let observedElement: T | null = null; let observedElement: T | null = null;
let mutationObserver: MutationObserver | null = null; let mutationObserver: MutationObserver | null = null;
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
@@ -32,8 +47,19 @@ export function useBottomFollowScroll<T extends HTMLElement>(containerRef: Ref<T
}); });
function onScroll(): void { function onScroll(): void {
if (programmaticScroll) return; const element = containerRef.value;
syncFollowingFromPosition(); if (!element) return;
const currentScrollTop = element.scrollTop;
if (!programmaticScroll) {
following.value = resolveBottomFollowingAfterScroll({
following: following.value,
previousScrollTop: lastScrollTop,
currentScrollTop,
distanceToBottom: distanceToBottom(element),
threshold
});
}
lastScrollTop = currentScrollTop;
} }
async function keepBottomAfterUpdate(): Promise<void> { async function keepBottomAfterUpdate(): Promise<void> {
@@ -64,6 +90,7 @@ export function useBottomFollowScroll<T extends HTMLElement>(containerRef: Ref<T
const element = containerRef.value; const element = containerRef.value;
if (!element) return; if (!element) return;
following.value = distanceToBottom(element) <= threshold; following.value = distanceToBottom(element) <= threshold;
lastScrollTop = element.scrollTop;
} }
function scheduleBottomAlignment(): void { function scheduleBottomAlignment(): void {
@@ -79,7 +106,9 @@ export function useBottomFollowScroll<T extends HTMLElement>(containerRef: Ref<T
function alignBottom(element: HTMLElement): void { function alignBottom(element: HTMLElement): void {
markProgrammaticScroll(); markProgrammaticScroll();
element.scrollTo({ top: maxScrollTop(element), behavior: "auto" }); const scrollTop = maxScrollTop(element);
lastScrollTop = scrollTop;
element.scrollTo({ top: scrollTop, behavior: "auto" });
} }
function markProgrammaticScroll(): void { function markProgrammaticScroll(): void {
@@ -88,7 +117,8 @@ export function useBottomFollowScroll<T extends HTMLElement>(containerRef: Ref<T
releaseProgrammaticFrame = window.requestAnimationFrame(() => { releaseProgrammaticFrame = window.requestAnimationFrame(() => {
releaseProgrammaticFrame = null; releaseProgrammaticFrame = null;
programmaticScroll = false; programmaticScroll = false;
syncFollowingFromPosition(); const element = containerRef.value;
if (element && following.value && distanceToBottom(element) > threshold) scheduleBottomAlignment();
}); });
} }
@@ -97,9 +127,12 @@ export function useBottomFollowScroll<T extends HTMLElement>(containerRef: Ref<T
disconnectObservers(); disconnectObservers();
observedElement = element; observedElement = element;
if (!element) return; if (!element) return;
lastScrollTop = element.scrollTop;
const onContentChanged = () => { const onContentChanged = (records: MutationRecord[]) => {
refreshResizeTargets(element); if (records.some((record) => record.type === "childList" && record.target === element)) {
refreshResizeTargets(element);
}
if (following.value) scheduleBottomAlignment(); if (following.value) scheduleBottomAlignment();
}; };
mutationObserver = new MutationObserver(onContentChanged); mutationObserver = new MutationObserver(onContentChanged);
@@ -140,5 +173,5 @@ export function useBottomFollowScroll<T extends HTMLElement>(containerRef: Ref<T
return Math.max(0, element.scrollHeight - element.clientHeight); return Math.max(0, element.scrollHeight - element.clientHeight);
} }
return { following, keepBottomAfterUpdate, onScroll, pauseFollowing, resumeFollowing, scrollToBottom, toggleFollowing }; return { following, keepBottomAfterUpdate, onScroll, pauseFollowing, resumeFollowing, scrollToBottom, syncFollowingFromPosition, toggleFollowing };
} }
@@ -158,6 +158,7 @@ export function useWorkbenchBottomFollowScroll<T extends HTMLElement>(containerR
const pos = scrollPersistence.scroll(sessionKey.value, tabKey); const pos = scrollPersistence.scroll(sessionKey.value, tabKey);
if (!pos) return; if (!pos) return;
element.scrollTo({ left: pos.x, top: pos.y, behavior: "auto" }); element.scrollTo({ left: pos.x, top: pos.y, behavior: "auto" });
bottom.syncFollowingFromPosition();
} }
function persistScroll(): void { function persistScroll(): void {
@@ -11,6 +11,7 @@ import { createWorkbenchHealthProbeCache } from "@/utils/workbench-health";
import { agentErrorFromProjection, normalizeApiErrorRecord, normalizeErrorDiagnostic, normalizeProjectionDiagnostic, projectionDiagnosticFromApiFailure, projectionDiagnosticFromFailure } from "@/utils/workbench-error-runtime"; import { agentErrorFromProjection, normalizeApiErrorRecord, normalizeErrorDiagnostic, normalizeProjectionDiagnostic, projectionDiagnosticFromApiFailure, projectionDiagnosticFromFailure } from "@/utils/workbench-error-runtime";
import { readWorkbenchJson, readWorkbenchNumber, readWorkbenchString, removeWorkbenchStorageKey, writeWorkbenchJson, writeWorkbenchString } from "@/utils/workbench-storage-runtime"; import { readWorkbenchJson, readWorkbenchNumber, readWorkbenchString, removeWorkbenchStorageKey, writeWorkbenchJson, writeWorkbenchString } from "@/utils/workbench-storage-runtime";
import { createWorkbenchStreamTransportRuntime, workbenchRealtimeTraceIdForCapabilities, type WorkbenchRealtimeEvent, type WorkbenchStreamTransportRecovery } from "@/utils/workbench-realtime-runtime"; import { createWorkbenchStreamTransportRuntime, workbenchRealtimeTraceIdForCapabilities, type WorkbenchRealtimeEvent, type WorkbenchStreamTransportRecovery } from "@/utils/workbench-realtime-runtime";
import { createWorkbenchReplayVisibilityRuntime } from "@/utils/workbench-replay-visibility";
import { mergeRunnerTrace, type TraceSnapshot } from "@/composables/workbench-trace-snapshot"; import { mergeRunnerTrace, type TraceSnapshot } from "@/composables/workbench-trace-snapshot";
import type { AgentChatResponse, AgentChatResultResponse, ApiError, ApiResult, ChatMessage, ErrorDiagnostic, LiveSurface, ProjectionBlocker, ProjectionDiagnostic, ProviderProfile, TraceEvent, WorkbenchSessionRecord, WorkbenchTurnTimingProjection } from "@/types"; import type { AgentChatResponse, AgentChatResultResponse, ApiError, ApiResult, ChatMessage, ErrorDiagnostic, LiveSurface, ProjectionBlocker, ProjectionDiagnostic, ProviderProfile, TraceEvent, WorkbenchSessionRecord, WorkbenchTurnTimingProjection } from "@/types";
import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils"; import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils";
@@ -124,6 +125,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const turnStatusAuthority = computed(() => selectTurnStatusAuthority(serverState.value)); const turnStatusAuthority = computed(() => selectTurnStatusAuthority(serverState.value));
const traceAuthorityById = computed(() => selectTraceAuthorityById(serverState.value)); const traceAuthorityById = computed(() => selectTraceAuthorityById(serverState.value));
const realtimeTransport = createWorkbenchStreamTransportRuntime(); const realtimeTransport = createWorkbenchStreamTransportRuntime();
const replayVisibility = createWorkbenchReplayVisibilityRuntime();
const workbenchProjectionSignalSourceId = nextProtocolId("wbtab"); const workbenchProjectionSignalSourceId = nextProtocolId("wbtab");
let workbenchProjectionSignalChannel: BroadcastChannel | null = null; let workbenchProjectionSignalChannel: BroadcastChannel | null = null;
const workbenchHealthProbeCache = createWorkbenchHealthProbeCache({ cacheMs: runtimePolicy.workbenchReadFailureCooldownMs }); const workbenchHealthProbeCache = createWorkbenchHealthProbeCache({ cacheMs: runtimePolicy.workbenchReadFailureCooldownMs });
@@ -862,6 +864,9 @@ export const useWorkbenchStore = defineStore("workbench", () => {
realtimeReplayLoadingSessionId.value = realtimeCapabilities.kafkaRefreshReplay ? sessionId : null; realtimeReplayLoadingSessionId.value = realtimeCapabilities.kafkaRefreshReplay ? sessionId : null;
realtimeTransportPhase.value = "connecting"; realtimeTransportPhase.value = "connecting";
} }
if (realtimeCapabilities.liveKafkaSse && realtimeCapabilities.kafkaRefreshReplay && (scopeChanged || forceReconnect)) {
replayVisibility.begin(realtimeScopeKey);
}
if (debugCapabilities.rawHwlabEventWindow.enabled && scopeChanged) rawHwlabIngress.value = createRawHwlabIngressState(realtimeScopeKey); if (debugCapabilities.rawHwlabEventWindow.enabled && scopeChanged) rawHwlabIngress.value = createRawHwlabIngressState(realtimeScopeKey);
realtimeTransport.restart({ realtimeTransport.restart({
realtimeCapabilities, realtimeCapabilities,
@@ -876,16 +881,23 @@ export const useWorkbenchStore = defineStore("workbench", () => {
? (frame) => { rawHwlabIngress.value = appendRawHwlabIngressFrame(rawHwlabIngress.value, frame, debugCapabilities.rawHwlabEventWindow); } ? (frame) => { rawHwlabIngress.value = appendRawHwlabIngressFrame(rawHwlabIngress.value, frame, debugCapabilities.rawHwlabEventWindow); }
: undefined, : undefined,
onError: () => { onError: () => {
replayVisibility.discard(realtimeScopeKey);
if (realtimeCapabilities.liveKafkaSse) liveRealtimeReadySessionId.value = null; if (realtimeCapabilities.liveKafkaSse) liveRealtimeReadySessionId.value = null;
}, },
onState: (state) => { onState: (state) => {
realtimeTransportPhase.value = state.phase; realtimeTransportPhase.value = state.phase;
if (realtimeCapabilities.liveKafkaSse && ["connecting", "error", "closed", "blocked"].includes(state.phase)) liveRealtimeReadySessionId.value = null; if (realtimeCapabilities.liveKafkaSse && ["connecting", "error", "closed", "blocked"].includes(state.phase)) liveRealtimeReadySessionId.value = null;
if (["error", "closed", "blocked"].includes(state.phase)) replayVisibility.discard(realtimeScopeKey);
}, },
onRecovery: (recovery) => handleRealtimeRecovery(recovery), onRecovery: (recovery) => handleRealtimeRecovery(recovery),
onEvent: (event, eventName) => { onEvent: (event, eventName) => {
if (realtimeCapabilities.liveKafkaSse && eventName === "workbench.connected") { if (realtimeCapabilities.liveKafkaSse && eventName === "workbench.connected") {
const assessment = assessWorkbenchRealtimeConnected(event, sessionId, realtimeCapabilities.kafkaRefreshReplay); const assessment = assessWorkbenchRealtimeConnected(event, sessionId, realtimeCapabilities.kafkaRefreshReplay);
if (assessment.ready) {
for (const frame of replayVisibility.commit(realtimeScopeKey)) applyRealtimeEvent(frame.event, frame.eventName);
} else {
replayVisibility.discard(realtimeScopeKey);
}
liveRealtimeReadySessionId.value = assessment.ready ? assessment.connectedSessionId : null; liveRealtimeReadySessionId.value = assessment.ready ? assessment.connectedSessionId : null;
if (assessment.ready && realtimeReplayLoadingSessionId.value === assessment.connectedSessionId) realtimeReplayLoadingSessionId.value = null; if (assessment.ready && realtimeReplayLoadingSessionId.value === assessment.connectedSessionId) realtimeReplayLoadingSessionId.value = null;
if (assessment.warning) { if (assessment.warning) {
@@ -911,6 +923,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
error.value = null; error.value = null;
} }
} }
if (replayVisibility.stage(realtimeScopeKey, { event, eventName })) return;
applyRealtimeEvent(event, eventName); applyRealtimeEvent(event, eventName);
} }
}); });
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import type { WorkbenchRealtimeEvent } from "@/api/workbench-events";
import { createWorkbenchReplayVisibilityRuntime } from "./workbench-replay-visibility";
describe("Workbench replay visibility", () => {
it("keeps the visible projection stable until retained replay reaches live handoff", () => {
const runtime = createWorkbenchReplayVisibilityRuntime();
let visibleLastEventAt = "2026-07-21T12:00:00.000Z";
const apply = (event: WorkbenchRealtimeEvent) => {
visibleLastEventAt = String(event.event?.createdAt);
};
runtime.begin("session:ses_running");
expect(runtime.stage("session:ses_running", kafkaFrame("2026-07-21T11:55:00.000Z"))).toBe(true);
expect(runtime.stage("session:ses_running", kafkaFrame("2026-07-21T11:58:00.000Z"))).toBe(true);
expect(runtime.stage("session:ses_running", kafkaFrame("2026-07-21T12:00:01.000Z"))).toBe(true);
expect(visibleLastEventAt).toBe("2026-07-21T12:00:00.000Z");
for (const frame of runtime.commit("session:ses_running")) apply(frame.event);
expect(visibleLastEventAt).toBe("2026-07-21T12:00:01.000Z");
expect(runtime.stage("session:ses_running", kafkaFrame("2026-07-21T12:00:03.000Z"))).toBe(false);
apply(kafkaFrame("2026-07-21T12:00:03.000Z").event);
expect(visibleLastEventAt).toBe("2026-07-21T12:00:03.000Z");
});
it("discards a partial replay when its stream is replaced", () => {
const runtime = createWorkbenchReplayVisibilityRuntime();
runtime.begin("session:ses_old");
runtime.stage("session:ses_old", kafkaFrame("2026-07-21T11:55:00.000Z"));
runtime.begin("session:ses_current");
expect(runtime.commit("session:ses_old")).toEqual([]);
expect(runtime.commit("session:ses_current")).toEqual([]);
});
it("does not delay non-business errors during replay", () => {
const runtime = createWorkbenchReplayVisibilityRuntime();
runtime.begin("session:ses_running");
expect(runtime.stage("session:ses_running", {
eventName: "workbench.error",
event: { type: "error", error: "upstream unavailable" }
})).toBe(false);
});
});
function kafkaFrame(createdAt: string) {
return {
eventName: "hwlab.event.v1",
event: {
schema: "hwlab.event.v1",
event: { traceId: "trc_replay", sessionId: "ses_running", createdAt }
} as WorkbenchRealtimeEvent
};
}
@@ -0,0 +1,47 @@
// SPEC: PJ2026-010401080313 Workbench realtime authority draft-2026-07-20-p0-kafka-sse-single-projection.
// Responsibility: Keep a retained Kafka replay invisible until its SSE live handoff is complete.
import type { WorkbenchRealtimeEvent } from "@/api/workbench-events";
export interface WorkbenchReplayVisibilityFrame {
event: WorkbenchRealtimeEvent;
eventName: string;
}
export class WorkbenchReplayVisibilityRuntime {
private scopeKey: string | null = null;
private frames: WorkbenchReplayVisibilityFrame[] = [];
begin(scopeKey: string): void {
this.scopeKey = scopeKey;
this.frames = [];
}
stage(scopeKey: string, frame: WorkbenchReplayVisibilityFrame): boolean {
if (scopeKey !== this.scopeKey || !isKafkaBusinessFrame(frame)) return false;
this.frames.push(frame);
return true;
}
commit(scopeKey: string): WorkbenchReplayVisibilityFrame[] {
if (scopeKey !== this.scopeKey) return [];
const frames = this.frames;
this.scopeKey = null;
this.frames = [];
return frames;
}
discard(scopeKey?: string | null): void {
if (scopeKey && scopeKey !== this.scopeKey) return;
this.scopeKey = null;
this.frames = [];
}
}
export function createWorkbenchReplayVisibilityRuntime(): WorkbenchReplayVisibilityRuntime {
return new WorkbenchReplayVisibilityRuntime();
}
function isKafkaBusinessFrame(frame: WorkbenchReplayVisibilityFrame): boolean {
return frame.eventName === "hwlab.event.v1" || frame.event.schema === "hwlab.event.v1";
}
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { WorkbenchRealtimeCapabilities } from "@/config/runtime";
import { WorkbenchStreamTransportRuntime, type WorkbenchStreamTransportState } from "./workbench-stream-transport";
const capabilities: WorkbenchRealtimeCapabilities = {
liveKafkaSse: true,
kafkaRefreshReplay: true,
projectionRealtime: false
};
describe("WorkbenchStreamTransportRuntime connection authority", () => {
const originalEventSource = globalThis.EventSource;
beforeEach(() => {
FakeEventSource.instances = [];
globalThis.EventSource = FakeEventSource as unknown as typeof EventSource;
});
afterEach(() => {
globalThis.EventSource = originalEventSource;
});
it("ignores late callbacks from a replaced SSE connection", () => {
const runtime = new WorkbenchStreamTransportRuntime();
const states: WorkbenchStreamTransportState[] = [];
const recoveries: string[] = [];
runtime.restart({
realtimeCapabilities: capabilities,
sessionId: "ses_old",
onState: (state) => states.push(state),
onRecovery: (recovery) => recoveries.push(recovery.key),
onEvent: () => undefined
});
const oldSource = FakeEventSource.instances[0];
expect(oldSource).toBeDefined();
const lateOpen = oldSource?.onopen;
const lateError = oldSource?.onerror;
runtime.restart({
realtimeCapabilities: capabilities,
sessionId: "ses_current",
onState: (state) => states.push(state),
onRecovery: (recovery) => recoveries.push(recovery.key),
onEvent: () => undefined
});
const currentSource = FakeEventSource.instances[1];
expect(currentSource).toBeDefined();
expect(states.at(-1)).toMatchObject({ sessionId: "ses_current", phase: "connecting" });
lateOpen?.(new Event("open"));
lateError?.(new Event("error"));
expect(states.at(-1)).toMatchObject({ sessionId: "ses_current", phase: "connecting" });
expect(recoveries).toEqual([]);
currentSource?.emitOpen();
expect(states.at(-1)).toMatchObject({ sessionId: "ses_current", phase: "open" });
runtime.stop();
});
});
class FakeEventSource {
static instances: FakeEventSource[] = [];
onopen: ((event: Event) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
constructor(readonly url: string | URL) {
FakeEventSource.instances.push(this);
}
addEventListener(): void {}
removeEventListener(): void {}
close(): void {}
emitOpen(): void {
this.onopen?.(new Event("open"));
}
}
@@ -97,6 +97,7 @@ export function workbenchRealtimeTransportEnabled(capabilities: WorkbenchRealtim
export class WorkbenchStreamTransportRuntime { export class WorkbenchStreamTransportRuntime {
private stream: WorkbenchEventStream | null = null; private stream: WorkbenchEventStream | null = null;
private key = ""; private key = "";
private generation = 0;
private tick = 0; private tick = 0;
private wait: WorkbenchTransportWait | null = null; private wait: WorkbenchTransportWait | null = null;
private readonly cursorByKey = new Map<string, WorkbenchTransportCursor>(); private readonly cursorByKey = new Map<string, WorkbenchTransportCursor>();
@@ -107,6 +108,7 @@ export class WorkbenchStreamTransportRuntime {
if (key === this.key && this.stream && input.forceReconnect !== true) return { key, changed: false, streamStarted: true }; if (key === this.key && this.stream && input.forceReconnect !== true) return { key, changed: false, streamStarted: true };
this.stop(); this.stop();
this.key = key; this.key = key;
const generation = this.generation;
this.armWait(key); this.armWait(key);
if (!workbenchRealtimeTransportEnabled(input.realtimeCapabilities)) return { key, changed: true, streamStarted: false }; if (!workbenchRealtimeTransportEnabled(input.realtimeCapabilities)) return { key, changed: true, streamStarted: false };
if (!input.sessionId && !input.traceId) return { key, changed: true, streamStarted: false }; if (!input.sessionId && !input.traceId) return { key, changed: true, streamStarted: false };
@@ -121,17 +123,20 @@ export class WorkbenchStreamTransportRuntime {
flushYieldMs: input.flushYieldMs, flushYieldMs: input.flushYieldMs,
onIngress: input.onIngress, onIngress: input.onIngress,
onOpen: () => { onOpen: () => {
if (!this.isCurrentStream(key, generation)) return;
this.markLive(key); this.markLive(key);
this.emitState(input, "open", null); this.emitState(input, "open", null);
input.onOpen?.(); input.onOpen?.();
}, },
onError: (event) => { onError: (event) => {
if (!this.isCurrentStream(key, generation)) return;
this.markBlocked(key); this.markBlocked(key);
this.emitState(input, "error", null, event.type); this.emitState(input, "error", null, event.type);
input.onError?.(event); input.onError?.(event);
this.requestRecovery(input, event.type || "eventsource-error"); this.requestRecovery(input, event.type || "eventsource-error");
}, },
onEvent: (event, eventName) => { onEvent: (event, eventName) => {
if (!this.isCurrentStream(key, generation)) return;
if (!input.realtimeCapabilities.liveKafkaSse && input.realtimeCapabilities.projectionRealtime) this.rememberCursor(key, event); if (!input.realtimeCapabilities.liveKafkaSse && input.realtimeCapabilities.projectionRealtime) this.rememberCursor(key, event);
this.markLive(key); this.markLive(key);
this.emitState(input, "event", eventName); this.emitState(input, "event", eventName);
@@ -143,6 +148,7 @@ export class WorkbenchStreamTransportRuntime {
stop(): void { stop(): void {
const previousKey = this.key; const previousKey = this.key;
this.generation += 1;
this.stream?.close(); this.stream?.close();
this.stream = null; this.stream = null;
this.key = ""; this.key = "";
@@ -157,6 +163,10 @@ export class WorkbenchStreamTransportRuntime {
return this.cursorByKey.get(key) ?? { outboxSeq: null, traceSeq: null }; return this.cursorByKey.get(key) ?? { outboxSeq: null, traceSeq: null };
} }
private isCurrentStream(key: string, generation: number): boolean {
return this.stream !== null && this.key === key && this.generation === generation;
}
clear(key?: string | null): void { clear(key?: string | null): void {
const normalized = normalizeKey(key); const normalized = normalizeKey(key);
if (normalized) { if (normalized) {