fix(workbench): show and accelerate Kafka replay

This commit is contained in:
root
2026-07-20 04:45:15 +02:00
parent f3563f7327
commit 57af108d40
10 changed files with 107 additions and 21 deletions
+8 -1
View File
@@ -6,7 +6,7 @@ import { test } from "bun:test";
import { createCloudApiBunServer } from "./bun-server.ts";
import { buildCloudApiReadiness } from "./health-contract.ts";
import { decodeCanonicalAgentRunKafkaMessage, kafkaDnsLookup, kafkaEventBridgeConfig, kafkaPartitionForKey, projectAgentRunKafkaEventToHwlabEvent, publishAgentRunKafkaMessageLive, relayHwlabKafkaOutboxOnce, startHwlabKafkaEventBridge } from "./kafka-event-bridge.ts";
import { decodeCanonicalAgentRunKafkaMessage, kafkaDnsLookup, kafkaEventBridgeConfig, kafkaMessageKeyCanMatchPartitionKey, kafkaPartitionForKey, projectAgentRunKafkaEventToHwlabEvent, publishAgentRunKafkaMessageLive, relayHwlabKafkaOutboxOnce, startHwlabKafkaEventBridge } from "./kafka-event-bridge.ts";
test("Workbench refresh replay resolves the same Kafka partition as the session-keyed producer", () => {
const offsets = [
@@ -23,6 +23,13 @@ test("Workbench refresh replay resolves the same Kafka partition as the session-
assert.equal(kafkaPartitionForKey(null, "hwlab.event.v1", offsets), null);
});
test("Workbench refresh replay rejects foreign keyed values before JSON parsing", () => {
assert.equal(kafkaMessageKeyCanMatchPartitionKey("ses_target", "ses_target"), true);
assert.equal(kafkaMessageKeyCanMatchPartitionKey("ses_foreign", "ses_target"), false);
assert.equal(kafkaMessageKeyCanMatchPartitionKey(null, "ses_target"), true);
assert.equal(kafkaMessageKeyCanMatchPartitionKey("ses_target", null), true);
});
const PROJECTOR_ENV = Object.freeze({
HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED: "false",
HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED: "false",
+31 -16
View File
@@ -973,6 +973,7 @@ export async function queryKafkaEventStream({ env = process.env, stream = "hwlab
const clientId = stringValue(env.HWLAB_KAFKA_CLIENT_ID) || DEFAULT_CLIENT_ID;
const resolvedTopic = stringValue(topic) || kafkaTopicForStream(stream, env);
const maxEvents = Math.max(1, integerValue(limit) || DEFAULT_QUERY_LIMIT);
const resolvedPartitionKey = stringValue(partitionKey);
const maxScannedRecords = scanLimit === null || scanLimit === undefined
? null
: strictPositiveInteger(scanLimit, "scanLimit");
@@ -989,7 +990,7 @@ export async function queryKafkaEventStream({ env = process.env, stream = "hwlab
timing.endOffsetSnapshotMs = Date.now() - stageStartedAtMs;
const endOffsetByPartition = new Map(endOffsetSnapshot.offsets.map((entry) => [entry.partition, entry.endOffset]));
const startOffsetByPartition = new Map(endOffsetSnapshot.offsets.map((entry) => [entry.partition, entry.startOffset]));
const targetPartition = kafkaPartitionForKey(partitionKey, resolvedTopic, endOffsetSnapshot.offsets);
const targetPartition = kafkaPartitionForKey(resolvedPartitionKey, resolvedTopic, endOffsetSnapshot.offsets);
const skippedPartitions = targetPartition === null ? [] : endOffsetSnapshot.offsets.filter((entry) => entry.partition !== targetPartition).map((entry) => entry.partition);
const requiresScopedGroupOffsets = skippedPartitions.length > 0;
const reachedEndPartitions = new Set([...skippedPartitions, ...endOffsetSnapshot.offsets.filter(kafkaOffsetPartitionIsEmpty).map((entry) => entry.partition)]);
@@ -1009,6 +1010,7 @@ export async function queryKafkaEventStream({ env = process.env, stream = "hwlab
let parsedCount = 0;
let invalidJsonCount = 0;
let filterRejectedCount = 0;
let keyRejectedCount = 0;
let excludedPostBarrierCount = 0;
let completionReason = "timeout";
let timer = null;
@@ -1090,22 +1092,28 @@ export async function queryKafkaEventStream({ env = process.env, stream = "hwlab
}
scannedCount += 1;
lastScannedOffsetByPartition.set(partition, stringValue(message.offset));
const valueText = message.value ? Buffer.from(message.value).toString("utf8") : "";
const value = parseJson(valueText);
if (!value || typeof value !== "object" || Array.isArray(value)) invalidJsonCount += 1;
else {
parsedCount += 1;
if (!eventMatchesFilters(value, { traceId, sessionId, runId, commandId })) filterRejectedCount += 1;
const messageKey = message.key ? Buffer.from(message.key).toString("utf8") : null;
if (!kafkaMessageKeyCanMatchPartitionKey(messageKey, resolvedPartitionKey)) {
filterRejectedCount += 1;
keyRejectedCount += 1;
} else {
const valueText = message.value ? Buffer.from(message.value).toString("utf8") : "";
const value = parseJson(valueText);
if (!value || typeof value !== "object" || Array.isArray(value)) invalidJsonCount += 1;
else {
events.push({
topic: messageTopic,
partition,
offset: message.offset,
key: message.key ? Buffer.from(message.key).toString("utf8") : null,
timestamp: message.timestamp ?? null,
valueSha256: sha256(valueText),
value
});
parsedCount += 1;
if (!eventMatchesFilters(value, { traceId, sessionId, runId, commandId })) filterRejectedCount += 1;
else {
events.push({
topic: messageTopic,
partition,
offset: message.offset,
key: messageKey,
timestamp: message.timestamp ?? null,
valueSha256: sha256(valueText),
value
});
}
}
}
if (fromBeginning !== false && endOffset && kafkaOffsetReached(message.offset, endOffset)) reachedEndPartitions.add(partition);
@@ -1153,6 +1161,7 @@ export async function queryKafkaEventStream({ env = process.env, stream = "hwlab
matchedCount: events.length,
invalidJsonCount,
filterRejectedCount,
keyRejectedCount,
excludedPostBarrierCount,
completionReason,
completion: {
@@ -1190,6 +1199,12 @@ export async function queryKafkaEventStream({ env = process.env, stream = "hwlab
}
}
export function kafkaMessageKeyCanMatchPartitionKey(messageKey, partitionKey) {
const expected = stringValue(partitionKey);
const actual = stringValue(messageKey);
return !expected || !actual || actual === expected;
}
export function kafkaPartitionForKey(partitionKey, topic, offsets = []) {
const key = stringValue(partitionKey);
const partitionMetadata = offsets
@@ -778,7 +778,10 @@ async function handleKafkaRefreshReplayWorkbenchRealtimeHttp(request, response,
completionReason: result?.completionReason ?? null,
complete: result?.completion?.complete === true,
scannedCount: result?.scannedCount ?? null,
parsedCount: result?.parsedCount ?? null,
matchedCount: result?.matchedCount ?? null,
filterRejectedCount: result?.filterRejectedCount ?? null,
keyRejectedCount: result?.keyRejectedCount ?? null,
partitionKeyScoped: result?.partitionKeyScoped === true,
targetPartition: result?.targetPartition ?? null,
timing: result?.timing ?? null,
@@ -403,7 +403,10 @@ function boundedQueryDiagnostics(result) {
completionReason: textValue(result?.completion?.reason ?? result?.completionReason),
complete: result?.completion?.complete === true,
scannedCount: numericValue(result?.scannedCount),
parsedCount: numericValue(result?.parsedCount),
matchedCount: numericValue(result?.matchedCount),
filterRejectedCount: numericValue(result?.filterRejectedCount),
keyRejectedCount: numericValue(result?.keyRejectedCount),
partitionKeyScoped: result?.partitionKeyScoped === true,
targetPartition: partitionValue(result?.targetPartition),
timing: {
+3
View File
@@ -170,7 +170,10 @@ function nativeKafkaRefreshEventStream(bridge: any, sessionId: string, traceId:
completionReason: result?.completionReason ?? null,
complete: result?.completion?.complete === true,
scannedCount: result?.scannedCount ?? null,
parsedCount: result?.parsedCount ?? null,
matchedCount: result?.matchedCount ?? null,
filterRejectedCount: result?.filterRejectedCount ?? null,
keyRejectedCount: result?.keyRejectedCount ?? null,
partitionKeyScoped: result?.partitionKeyScoped === true,
targetPartition: result?.targetPartition ?? null,
timing: result?.timing ?? null,
+6
View File
@@ -185,7 +185,10 @@ describe("Workbench native HTTP adapter", () => {
endOffsetsAvailable: true,
endOffsets: [{ partition: 0, startOffset: "0", endOffset: "100" }],
scannedCount: 100,
parsedCount: 100,
matchedCount: 100,
filterRejectedCount: 0,
keyRejectedCount: 0,
partitionKeyScoped: true,
targetPartition: 0,
timing: { endOffsetSnapshotMs: 2, groupOffsetsMs: 0, consumerConnectMs: 3, consumerSubscribeMs: 1, scanMs: 7, cleanupMs: 1, totalMs: 14 }
@@ -222,7 +225,10 @@ describe("Workbench native HTTP adapter", () => {
query: {
complete: true,
scannedCount: 100,
parsedCount: 100,
matchedCount: 100,
filterRejectedCount: 0,
keyRejectedCount: 0,
partitionKeyScoped: true,
targetPartition: 0,
timing: { scanMs: 7, totalMs: 14 }
+3
View File
@@ -178,7 +178,10 @@ function workbenchConnectedContract(data: Record<string, any>) {
completionReason: String(query.completionReason ?? "").trim() || null,
complete: query.complete === true,
scannedCount: nullableNumber(query.scannedCount),
parsedCount: nullableNumber(query.parsedCount),
matchedCount: nullableNumber(query.matchedCount),
filterRejectedCount: nullableNumber(query.filterRejectedCount),
keyRejectedCount: nullableNumber(query.keyRejectedCount),
partitionKeyScoped: query.partitionKeyScoped === true,
targetPartition: nullableNumber(query.targetPartition),
timing: queryTiming ? {
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { test } from "bun:test";
import type { ChatMessage, WorkbenchSessionRecord } from "../types";
import { resolveCancelableAgentMessage, resolveComposerState, sessionToSessionTab, shouldReadTerminalTraceDetail } from "./workbench-session";
import { resolveCancelableAgentMessage, resolveComposerState, sessionToSessionTab, shouldReadTerminalTraceDetail, shouldShowSessionDetailLoading } from "./workbench-session";
import type { TurnStatusAuthority } from "./workbench-session";
function agentMessage(input: Partial<ChatMessage> & Pick<ChatMessage, "id" | "traceId" | "sessionId">): ChatMessage {
@@ -76,6 +76,27 @@ test("composer blocks turn submission until the live session stream is connected
assert.equal(connected.disabled, false);
});
test("session detail keeps a visible loading state until Kafka replay connects", () => {
assert.equal(shouldShowSessionDetailLoading({
loading: false,
messageCount: 0,
replayLoadingSessionId: "ses_refresh",
activeSessionId: "ses_refresh"
}), true);
assert.equal(shouldShowSessionDetailLoading({
loading: false,
messageCount: 2,
replayLoadingSessionId: null,
activeSessionId: "ses_refresh"
}), false);
assert.equal(shouldShowSessionDetailLoading({
loading: false,
messageCount: 0,
replayLoadingSessionId: "ses_previous",
activeSessionId: "ses_refresh"
}), false);
});
test("cancel target remains available for completed message until final response exists", () => {
const traceId = "trc_cancel_completed_without_final";
const sessionId = "ses_cancel_completed_without_final";
@@ -130,6 +130,19 @@ export function shouldShowSessionListLoading(input: { loading: boolean; sessions
return input.loading && !input.sessionsReady;
}
export function shouldShowSessionDetailLoading(input: {
loading: boolean;
messageCount: number;
sessionDetailLoadingId?: string | null;
switchingSessionId?: string | null;
replayLoadingSessionId?: string | null;
activeSessionId?: string | null;
}): boolean {
if (input.sessionDetailLoadingId || input.switchingSessionId) return true;
if (input.loading && input.messageCount === 0) return true;
return Boolean(input.replayLoadingSessionId && input.replayLoadingSessionId === input.activeSessionId);
}
export function sessionToSessionTab(session: WorkbenchSessionRecord, activeSessionId: string | null, sessionStatusAuthority: SessionStatusAuthorityMap = {}): SessionTab {
const sessionId = session.sessionId;
const updatedAt = sessionDisplayUpdatedAt(session);
+15 -3
View File
@@ -16,7 +16,7 @@ import type { AgentChatResponse, AgentChatResultResponse, ApiError, ApiResult, C
import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils";
import { composeWorkbenchScopedKey, workbenchRealtimeScopeKey } from "@/utils/workbench-key";
import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, recordWorkbenchLoadingState, recordWorkbenchRuntimeDiagnostic, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance";
import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, preferredProviderProfile, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldReadTerminalTraceDetail, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session";
import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, preferredProviderProfile, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldReadTerminalTraceDetail, shouldShowSessionDetailLoading, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session";
import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection";
import { cleanupWorkbenchServerStateSessions, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state";
import { cleanupDroppedWorkbenchSessionCaches, trimWorkbenchSessionCache } from "./workbench-session-cache";
@@ -113,6 +113,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const activityRef = ref({ lastActivityAt: Date.now(), lastActivityIso: new Date().toISOString(), waitingFor: "idle", lastEventLabel: null as string | null });
const currentRequest = ref<{ traceId: string; sessionId: string | null; threadId: string | null; status?: string | null } | null>(null);
const liveRealtimeReadySessionId = ref<string | null>(null);
const realtimeReplayLoadingSessionId = ref<string | null>(null);
const explicitSessionId = ref<string | null>(initialWorkbenchSessionIdFromLocation());
const activeSelectionSource = ref<SessionSelectionSource>(explicitSessionId.value ? "route" : "system");
const selectionEpoch = ref(0);
@@ -138,7 +139,14 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const sessionTabs = computed(() => sortSessionTabs(routeActiveSession.value ? mergeSessionIntoList(sessions.value, routeActiveSession.value) : sessions.value, activeSessionId.value, sessionStatusAuthority.value));
const sessionListLoadedCount = computed(() => sessionTabs.value.length);
const sessionListLoading = computed(() => shouldShowSessionListLoading({ loading: loading.value, sessionsReady: sessionsReady.value }));
const sessionDetailLoading = computed(() => Boolean(sessionDetailLoadingId.value || switchingSessionId.value || (loading.value && messages.value.length === 0)));
const sessionDetailLoading = computed(() => shouldShowSessionDetailLoading({
loading: loading.value,
messageCount: messages.value.length,
sessionDetailLoadingId: sessionDetailLoadingId.value,
switchingSessionId: switchingSessionId.value,
replayLoadingSessionId: realtimeReplayLoadingSessionId.value,
activeSessionId: activeSessionId.value
}));
const composer = computed(() => resolveComposerState({ messages: messages.value, sessions: sessions.value, activeSessionId: activeSessionId.value, chatPending: chatPending.value, realtimeReady: !realtimeCapabilities.liveKafkaSse || liveRealtimeReadySessionId.value === activeSessionId.value, currentRequest: currentRequest.value, turnStatusAuthority: turnStatusAuthority.value }));
function recordActivity(label = "user-activity"): void {
@@ -839,7 +847,10 @@ export const useWorkbenchStore = defineStore("workbench", () => {
void reason;
const realtimeScopeKey = workbenchRealtimeScopeKey(sessionId, traceId);
const scopeChanged = realtimeTransport.currentKey() !== realtimeScopeKey;
if (realtimeCapabilities.liveKafkaSse && scopeChanged) liveRealtimeReadySessionId.value = null;
if (realtimeCapabilities.liveKafkaSse && scopeChanged) {
liveRealtimeReadySessionId.value = null;
realtimeReplayLoadingSessionId.value = realtimeCapabilities.kafkaRefreshReplay ? sessionId : null;
}
if (debugCapabilities.rawHwlabEventWindow.enabled && scopeChanged) rawHwlabIngress.value = createRawHwlabIngressState(realtimeScopeKey);
realtimeTransport.restart({
realtimeCapabilities,
@@ -864,6 +875,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (realtimeCapabilities.liveKafkaSse && eventName === "workbench.connected") {
const assessment = assessWorkbenchRealtimeConnected(event, sessionId, realtimeCapabilities.kafkaRefreshReplay);
liveRealtimeReadySessionId.value = assessment.ready ? assessment.connectedSessionId : null;
if (assessment.ready && realtimeReplayLoadingSessionId.value === assessment.connectedSessionId) realtimeReplayLoadingSessionId.value = null;
if (assessment.warning) {
recordWorkbenchRuntimeDiagnostic({
module: "workbench-stream-contract",