From 6297a2ea1478b648b1cf931fbcca0cf54f5a7ad9 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 10 Jul 2026 15:58:51 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=BC=BA=E5=8C=96=20Workbench=20Kafka?= =?UTF-8?q?=20=E9=87=8D=E6=94=BE=E6=8E=A2=E9=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/unidesk-webdev/SKILL.md | 7 +- .../unidesk-webdev/references/web-probe.md | 5 +- scripts/src/hwlab-node-web-observe-render.ts | 89 +++- ...e-runner-kafka-debug-replay-source.test.ts | 98 ++++- ...bserve-runner-kafka-debug-replay-source.ts | 413 ++++++++++++++++-- .../src/hwlab-node/web-observe-render.test.ts | 21 + .../hwlab-node/web-observe-scripts.test.ts | 23 + scripts/src/hwlab-node/web-observe-scripts.ts | 3 +- .../web-probe-observe-actions.test.ts | 3 + .../hwlab-node/web-probe-observe-actions.ts | 12 +- 10 files changed, 619 insertions(+), 55 deletions(-) diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index 94ce01c8..8b6d78df 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -40,7 +40,12 @@ description: UniDesk Web 开发与浏览器验证技能。用户处理 UniDesk/H - MDTODO → Workbench launch 验收必须引用 `launchWorkbenchFromMdtodo` 的 command result,确认文件/任务选择、Workbench session、launch/chat 状态和 OTel trace header;不要只凭页面最终停在 Workbench URL 判断通过。 - Workbench provider profile 验收使用 `observe command --type sendPrompt --provider ` 时,command result 必须显示 `requestedProvider`、`providerSelection.selected`、`/v1/agent/chat` 202、traceId 和最终 `turn-summary`;只有 prompt 成功不够,必须确认浏览器控件实际选中目标 profile。 - Workbench 纯实时 fanout 的重复验收使用 `observe command --type validateRealtimeFanout --profile --provider --text --second-text `;它在 observer 后台维持两个独立 browser context 和三条 debug SSE,命令提交后按 `status --command-id ` 轮询,不得回退为长连接 `web-probe script`。 -- Workbench 隔离 Kafka debug 重放使用 `observe command --type validateWorkbenchKafkaDebugReplay`;它复用 observer 当前 session 和 semantic origin,依次清空隔离面板、重放当前 trace,并按 owning YAML 校验 topic、独立 group prefix、received/applied 计数和 completed 终态。 +- Workbench 隔离 Kafka debug 重放使用 `observe command --type validateWorkbenchKafkaDebugReplay`: + - 复用 observer 当前 session 和 semantic origin; + - 按 owning YAML 校验 topic、独立 group prefix、请求静默窗口和产品/调试 SSE 路径; + - 新 UI 优先报告 server/client 分层计数、typed phase/code、source-authority Trace 行和原始 HWLAB Event 有界计数; + - 切换原始事件标签不得新建第二条产品 EventSource; + - 旧 UI 缺少新 DOM 合同时保持明确的 `legacy-ui` 兼容证据。 - `observe analyze` 的 archive/history findings 可能包含旧样本或旧规则误判;关闭用户入口问题前同时核对最新 sample 字段、DOM 几何和截图。若 top-level findings、latest sample 和 archive red 冲突,说明冲突并补工具反馈 issue,不要只凭 archive red 或单张截图下结论。 ## 高频工作流 diff --git a/.agents/skills/unidesk-webdev/references/web-probe.md b/.agents/skills/unidesk-webdev/references/web-probe.md index 7d7f6b51..9319beb4 100644 --- a/.agents/skills/unidesk-webdev/references/web-probe.md +++ b/.agents/skills/unidesk-webdev/references/web-probe.md @@ -10,5 +10,8 @@ - Workbench 隔离 Kafka debug 重放规则: - 使用 `observe command --type validateWorkbenchKafkaDebugReplay`; - 命令复用 observer 的 semantic origin 和当前 session; - - topic、group prefix 和完成超时来自 owning YAML; + - topic、group prefix、完成超时、产品/调试 SSE 路径和请求静默窗口来自 owning YAML; + - 新 UI 同时报告分层重放计数、typed phase/code、source-authority Trace 行和原始 HWLAB Event 有界计数; + - 原始事件标签必须复用同页产品 EventSource,切换标签不得新增产品 SSE 请求; + - 旧 UI 缺少新 DOM 合同时报告 `legacy-ui`,不伪造新合同; - 禁止通过 URL/IP 或一次性脚本替代该命令。 diff --git a/scripts/src/hwlab-node-web-observe-render.ts b/scripts/src/hwlab-node-web-observe-render.ts index eb89587b..0d34e3d2 100644 --- a/scripts/src/hwlab-node-web-observe-render.ts +++ b/scripts/src/hwlab-node-web-observe-render.ts @@ -41,6 +41,7 @@ function renderWebObserveStatusTable(value: Record): string { const exactResult = record(exactCommand?.result); const exactError = record(exactCommand?.error); const exactErrorDetails = record(exactError?.details); + const exactReplayEvidence = exactResult ?? exactErrorDetails; const tails = record(observer?.tails); const result = record(value.result); const samples = Array.isArray(tails?.samples) ? tails.samples.map(record).filter((item): item is Record => item !== null).slice(-5) : []; @@ -175,17 +176,12 @@ function renderWebObserveStatusTable(value: Record): string { ]]), "", ] : []), - ...(exactCommand.type === "validateWorkbenchKafkaDebugReplay" && exactResult !== null ? [ - "Workbench Kafka debug replay:", - webObserveTable(["TRACE", "TOPIC", "GROUP", "RECEIVED", "APPLIED", "TERMINAL", "SCREENSHOT_SHA"], [[ - webObserveShort(webObserveText(exactResult.traceId), 28), - webObserveShort(webObserveText(exactResult.topic), 32), - webObserveShort(webObserveText(exactResult.groupId), 48), - exactResult.receivedCount, - exactResult.appliedCount, - exactResult.terminalStatus, - webObserveShort(webObserveText(record(exactResult.screenshot)?.sha256), 32), - ]]), + ...(exactCommand.type === "validateWorkbenchKafkaDebugReplay" && exactReplayEvidence !== null ? [ + ...renderWorkbenchKafkaDebugReplayEvidence( + exactReplayEvidence, + exactResult?.reportSha256 ?? exactErrorDetails?.reportSha256, + record(exactResult?.screenshot)?.sha256, + ), "", ] : []), "Recent samples:", @@ -232,6 +228,58 @@ function renderWebObserveStatusTable(value: Record): string { return lines.join("\n"); } +function renderWorkbenchKafkaDebugReplayEvidence( + evidence: Record, + reportSha: unknown, + screenshotSha: unknown, +): string[] { + const layers = record(evidence.layerCounts); + const server = record(layers?.server); + const client = record(layers?.client); + const trace = record(evidence.traceTimeline); + const raw = record(evidence.rawHwlabEventWindow); + const rawCounts = record(raw?.counts); + return [ + "Workbench Kafka debug replay:", + webObserveTable(["TRACE", "TOPIC", "GROUP", "PHASE", "CODE", "RECEIVED", "APPLIED", "TERMINAL", "REPORT_SHA", "SCREENSHOT_SHA"], [[ + webObserveShort(webObserveText(evidence.traceId), 28), + webObserveShort(webObserveText(evidence.topic), 32), + webObserveShort(webObserveText(evidence.groupId), 48), + evidence.phase, + evidence.code, + evidence.receivedCount, + evidence.appliedCount, + evidence.terminalStatus, + webObserveShort(webObserveText(reportSha), 32), + webObserveShort(webObserveText(screenshotSha), 32), + ]]), + "Replay layers:", + webObserveTable(["SERVER_SCANNED", "SERVER_MATCHED", "SERVER_DELIVERED", "CLIENT_RECEIVED", "CLIENT_DECODED", "CLIENT_APPLIED"], [[ + server?.scanned, + server?.matched, + server?.delivered, + client?.received, + client?.decoded, + client?.applied, + ]]), + "Trace and raw HWLAB Event:", + webObserveTable(["AUTHORITY", "SOURCE_ROWS", "READABLE_ROWS", "RAW_AVAILABLE", "SAME_PAGE", "UNFILTERED", "RAW_RECEIVED", "RAW_RETAINED", "RAW_REJECTED", "RAW_EVICTED", "RAW_BYTES", "EXTRA_PRODUCT_SSE"], [[ + trace?.contract, + trace?.sourceAuthorityRowCount, + trace?.readableSourceRowCount, + raw?.available, + raw?.samePage, + raw?.unfilteredContractPresent, + rawCounts?.received, + rawCounts?.retained, + rawCounts?.rejected, + rawCounts?.evicted, + rawCounts?.bytes, + raw?.productEventSourceRequestCount, + ]]), + ]; +} + function renderWebObserveCommandTable(value: Record): string { const observerCommand = record(value.observerCommand); const observer = record(value.observer); @@ -241,6 +289,7 @@ function renderWebObserveCommandTable(value: Record): string { const resultScreenshot = record(result?.screenshot); const error = record(observer?.error); const details = record(error?.details); + const replayEvidence = result ?? details; const rawReadiness = record(error?.navigationReadiness) ?? record(details?.readiness) ?? record(details?.readinessAfterWait) ?? record(details?.readinessBeforeClick); const readiness = record(rawReadiness?.snapshot) ?? rawReadiness; const id = webObserveText(value.id); @@ -259,19 +308,13 @@ function renderWebObserveCommandTable(value: Record): string { webObserveShort(webObserveText(asyncTurn?.traceId), 24), webObserveShort(webObserveText(result?.mark ?? result?.currentUrl ?? observer?.error ?? observer?.queued), 80), ]]), - ...(observerCommand?.type === "validateWorkbenchKafkaDebugReplay" && result !== null ? [ + ...(observerCommand?.type === "validateWorkbenchKafkaDebugReplay" && replayEvidence !== null ? [ "", - "Workbench Kafka debug replay:", - webObserveTable(["TRACE", "TOPIC", "GROUP", "RECEIVED", "APPLIED", "TERMINAL", "REPORT_SHA", "SCREENSHOT_SHA"], [[ - webObserveShort(webObserveText(result.traceId), 28), - webObserveShort(webObserveText(result.topic), 32), - webObserveShort(webObserveText(result.groupId), 48), - result.receivedCount, - result.appliedCount, - result.terminalStatus, - webObserveShort(webObserveText(result.reportSha256), 32), - webObserveShort(webObserveText(resultScreenshot?.sha256), 32), - ]]), + ...renderWorkbenchKafkaDebugReplayEvidence( + replayEvidence, + result?.reportSha256 ?? details?.reportSha256, + resultScreenshot?.sha256, + ), ] : []), ...(readiness !== null ? [ "", diff --git a/scripts/src/hwlab-node-web-observe-runner-kafka-debug-replay-source.test.ts b/scripts/src/hwlab-node-web-observe-runner-kafka-debug-replay-source.test.ts index 66cab6f8..bf36c3cd 100644 --- a/scripts/src/hwlab-node-web-observe-runner-kafka-debug-replay-source.test.ts +++ b/scripts/src/hwlab-node-web-observe-runner-kafka-debug-replay-source.test.ts @@ -6,9 +6,15 @@ import { nodeWebObserveRunnerSource } from "./hwlab-node-web-observe-runner-sour test("Workbench Kafka debug replay source parses the YAML contract and reducer counts", () => { const source = nodeWebObserveRunnerKafkaDebugReplaySource(); - const build = new Function(`${source}\nreturn { parseWorkbenchKafkaDebugReplayConfig, workbenchKafkaDebugReplayCounts };`) as () => { + const build = new Function(`${source}\nreturn { parseWorkbenchKafkaDebugReplayConfig, workbenchKafkaDebugReplayCounts, workbenchKafkaDebugReplayLayerCounts, workbenchKafkaDebugReplayRawCounts, workbenchKafkaDebugReplayTypedOutcome, workbenchKafkaDebugReplayValidateLayerCounts, workbenchKafkaDebugReplayValidateTraceTimeline, workbenchKafkaDebugReplayValidateRawWindow };`) as () => { parseWorkbenchKafkaDebugReplayConfig: (raw: string) => Record; workbenchKafkaDebugReplayCounts: (text: string) => Record | null; + workbenchKafkaDebugReplayLayerCounts: (text: string) => Record | null; + workbenchKafkaDebugReplayRawCounts: (text: string) => Record | null; + workbenchKafkaDebugReplayTypedOutcome: (input: Record) => Record; + workbenchKafkaDebugReplayValidateLayerCounts: (counts: Record | null, evidence: Record) => void; + workbenchKafkaDebugReplayValidateTraceTimeline: (timeline: Record, evidence: Record) => void; + workbenchKafkaDebugReplayValidateRawWindow: (window: Record, evidence: Record) => void; }; const helpers = build(); assert.deepEqual(helpers.parseWorkbenchKafkaDebugReplayConfig(JSON.stringify({ @@ -16,15 +22,101 @@ test("Workbench Kafka debug replay source parses the YAML contract and reducer c topic: "hwlab.event.debug.v1", groupPrefix: "hwlab-v03-workbench-isolated-debug", completionTimeoutMs: 30_000, + productEventsPath: "/v1/workbench/events", + debugEventsPath: "/v1/workbench/debug/kafka-sse/events", + requestObservationQuietMs: 3_000, })), { enabled: true, topic: "hwlab.event.debug.v1", groupPrefix: "hwlab-v03-workbench-isolated-debug", completionTimeoutMs: 30_000, + productEventsPath: "/v1/workbench/events", + debugEventsPath: "/v1/workbench/debug/kafka-sse/events", + requestObservationQuietMs: 3_000, valuesRedacted: true, }); assert.deepEqual(helpers.workbenchKafkaDebugReplayCounts("35/35 applied"), { appliedCount: 35, receivedCount: 35 }); assert.equal(helpers.workbenchKafkaDebugReplayCounts("等待重放"), null); + assert.deepEqual(helpers.workbenchKafkaDebugReplayLayerCounts( + "server scanned=27 · matched=2 · delivered=2 client local overlay received=2 · decoded=2 · applied=2", + ), { + server: { scanned: 27, matched: 2, delivered: 2 }, + client: { received: 2, decoded: 2, applied: 2 }, + valuesRedacted: true, + }); + assert.deepEqual(helpers.workbenchKafkaDebugReplayRawCounts( + "received=34 · retained=30 · decoded=33 · rejected=1 · evicted=4 · bytes=8192", + ), { + received: 34, + retained: 30, + decoded: 33, + rejected: 1, + evicted: 4, + bytes: 8192, + valuesRedacted: true, + }); + assert.deepEqual(helpers.workbenchKafkaDebugReplayTypedOutcome({ status: "completed", layerCounts: { server: {} } }), { + phase: "terminal", + code: "terminal_complete", + source: "typed-dom-v2", + valuesRedacted: true, + }); + assert.deepEqual(helpers.workbenchKafkaDebugReplayTypedOutcome({ status: "incomplete", errorText: "records_scanned_but_filter_mismatch: no replayId match" }), { + phase: "server", + code: "records_scanned_but_filter_mismatch", + source: "legacy-dom", + valuesRedacted: true, + }); + const evidence = { + phase: "terminal", + code: "terminal_complete", + traceId: "trc_fixture", + legacyCounts: { receivedCount: 2, appliedCount: 2 }, + }; + assert.throws( + () => helpers.workbenchKafkaDebugReplayValidateLayerCounts(null, evidence), + /typed server\/client count contract is missing/u, + ); + assert.throws( + () => helpers.workbenchKafkaDebugReplayValidateTraceTimeline({ authorityAvailable: false }, evidence), + /source-authority contract is missing/u, + ); + assert.throws( + () => helpers.workbenchKafkaDebugReplayValidateRawWindow({ available: false }, evidence), + /raw HWLAB Event window contract is missing/u, + ); + assert.throws( + () => helpers.workbenchKafkaDebugReplayValidateRawWindow({ + available: true, + samePage: true, + sourceContractPresent: true, + unfilteredContractPresent: true, + productEventSourceRequestCount: 0, + textPresent: false, + textBytes: 0, + counts: { received: 0, retained: 0, decoded: 0, rejected: 0, evicted: 0, bytes: 0 }, + }, evidence), + /did not retain a readable product SSE frame/u, + ); + assert.doesNotThrow(() => helpers.workbenchKafkaDebugReplayValidateLayerCounts({ + server: { scanned: 27, matched: 2, delivered: 2 }, + client: { received: 2, decoded: 2, applied: 2 }, + }, evidence)); + assert.doesNotThrow(() => helpers.workbenchKafkaDebugReplayValidateTraceTimeline({ + authorityAvailable: true, + sourceAuthorityRowCount: 2, + readableSourceRowCount: 2, + }, evidence)); + assert.doesNotThrow(() => helpers.workbenchKafkaDebugReplayValidateRawWindow({ + available: true, + samePage: true, + sourceContractPresent: true, + unfilteredContractPresent: true, + productEventSourceRequestCount: 0, + textPresent: true, + textBytes: 256, + counts: { received: 2, retained: 2, decoded: 2, rejected: 0, evicted: 0, bytes: 256 }, + }, evidence)); }); test("generated observer runner contains the typed debug replay command and passes node syntax check", async () => { @@ -33,6 +125,10 @@ test("generated observer runner contains the typed debug replay command and pass assert.match(source, /workbench-kafka-debug-toggle/u); assert.match(source, /workbench-kafka-debug-replay/u); assert.match(source, /workbench-kafka-debug-counts/u); + assert.match(source, /workbench-kafka-debug-layer-counts/u); + assert.match(source, /workbench-kafka-debug-tab-raw/u); + assert.match(source, /workbench-raw-hwlab-counts/u); + assert.match(source, /data-event-seq-authority/u); const child = Bun.spawn(["node", "--input-type=module", "--check", "-"], { stdin: "pipe", stdout: "pipe", stderr: "pipe" }); child.stdin.write(source); child.stdin.end(); diff --git a/scripts/src/hwlab-node-web-observe-runner-kafka-debug-replay-source.ts b/scripts/src/hwlab-node-web-observe-runner-kafka-debug-replay-source.ts index b9bfa4f9..dac8d4c3 100644 --- a/scripts/src/hwlab-node-web-observe-runner-kafka-debug-replay-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-kafka-debug-replay-source.ts @@ -16,13 +16,34 @@ function parseWorkbenchKafkaDebugReplayConfig(raw) { } const topic = String(parsed.topic || "").trim(); const groupPrefix = String(parsed.groupPrefix || "").trim(); + const productEventsPath = String(parsed.productEventsPath || "").trim(); + const debugEventsPath = String(parsed.debugEventsPath || "").trim(); const completionTimeoutMs = Number(parsed.completionTimeoutMs); + const requestObservationQuietMs = Number(parsed.requestObservationQuietMs); if (!/^[A-Za-z0-9._-]+$/u.test(topic)) throw new Error("Workbench Kafka debug replay topic is missing or invalid in the owning YAML"); if (!/^[A-Za-z0-9._-]+$/u.test(groupPrefix)) throw new Error("Workbench Kafka debug replay groupPrefix is missing or invalid in the owning YAML"); + if (!productEventsPath.startsWith("/") || productEventsPath.includes("?") || productEventsPath.includes("#")) { + throw new Error("Workbench Kafka debug replay productEventsPath must be an absolute path without query or fragment in the owning YAML"); + } + if (!debugEventsPath.startsWith("/") || debugEventsPath.includes("?") || debugEventsPath.includes("#")) { + throw new Error("Workbench Kafka debug replay debugEventsPath must be an absolute path without query or fragment in the owning YAML"); + } if (!Number.isInteger(completionTimeoutMs) || completionTimeoutMs < 1000 || completionTimeoutMs > 120000) { throw new Error("Workbench Kafka debug replay completionTimeoutMs must be 1000-120000 in the owning YAML"); } - return { enabled: parsed.enabled === true, topic, groupPrefix, completionTimeoutMs, valuesRedacted: true }; + if (!Number.isInteger(requestObservationQuietMs) || requestObservationQuietMs < 100 || requestObservationQuietMs > 30000) { + throw new Error("Workbench Kafka debug replay requestObservationQuietMs must be 100-30000 in the owning YAML"); + } + return { + enabled: parsed.enabled === true, + topic, + groupPrefix, + productEventsPath, + debugEventsPath, + completionTimeoutMs, + requestObservationQuietMs, + valuesRedacted: true + }; } function workbenchKafkaDebugReplayCounts(value) { @@ -30,32 +51,114 @@ function workbenchKafkaDebugReplayCounts(value) { return match ? { appliedCount: Number(match[1]), receivedCount: Number(match[2]) } : null; } +function workbenchKafkaDebugReplayNamedCount(value, name) { + const match = String(value || "").match(new RegExp("(?:^|\\s|·)" + name + "\\s*=\\s*(-|\\d+)", "iu")); + if (!match) return undefined; + return match[1] === "-" ? null : Number(match[1]); +} + +function workbenchKafkaDebugReplayLayerCounts(value) { + const text = String(value || "").replace(/\s+/gu, " ").trim(); + if (!/\bserver\b/iu.test(text) || !/\bclient\b/iu.test(text)) return null; + return { + server: { + scanned: workbenchKafkaDebugReplayNamedCount(text, "scanned"), + matched: workbenchKafkaDebugReplayNamedCount(text, "matched"), + delivered: workbenchKafkaDebugReplayNamedCount(text, "delivered") + }, + client: { + received: workbenchKafkaDebugReplayNamedCount(text, "received"), + decoded: workbenchKafkaDebugReplayNamedCount(text, "decoded"), + applied: workbenchKafkaDebugReplayNamedCount(text, "applied") + }, + valuesRedacted: true + }; +} + +function workbenchKafkaDebugReplayRawCounts(value) { + const text = String(value || "").replace(/\s+/gu, " ").trim(); + if (!/\breceived\s*=/iu.test(text) || !/\bretained\s*=/iu.test(text)) return null; + return { + received: workbenchKafkaDebugReplayNamedCount(text, "received"), + retained: workbenchKafkaDebugReplayNamedCount(text, "retained"), + decoded: workbenchKafkaDebugReplayNamedCount(text, "decoded"), + rejected: workbenchKafkaDebugReplayNamedCount(text, "rejected"), + evicted: workbenchKafkaDebugReplayNamedCount(text, "evicted"), + bytes: workbenchKafkaDebugReplayNamedCount(text, "bytes"), + valuesRedacted: true + }; +} + +function workbenchKafkaDebugReplayTypedOutcome(input) { + const status = String(input?.status || "unknown").trim() || "unknown"; + const errorText = String(input?.errorText || "").trim(); + const source = input?.layerCounts ? "typed-dom-v2" : "legacy-dom"; + const codeMatch = errorText.match(/\b(consumer_not_ready|source_trace_missing|producer_not_invoked|topic_no_append_for_replay|record_parse_rejected|records_scanned_but_filter_mismatch|sse_write_failed|transport_failed|decoder_rejected|reducer_rejected|terminal_missing)\b/u); + const phaseByCode = { + consumer_not_ready: "consumer", + source_trace_missing: "producer", + producer_not_invoked: "producer", + topic_no_append_for_replay: "kafka", + record_parse_rejected: "server", + records_scanned_but_filter_mismatch: "server", + sse_write_failed: "server", + transport_failed: "client", + decoder_rejected: "client", + reducer_rejected: "client", + terminal_missing: "terminal" + }; + if (codeMatch) return { phase: phaseByCode[codeMatch[1]], code: codeMatch[1], source, valuesRedacted: true }; + if (status === "completed") return { phase: "terminal", code: "terminal_complete", source, valuesRedacted: true }; + if (status === "incomplete" && /terminal|终态|未观测/iu.test(errorText)) { + return { phase: "terminal", code: "terminal_missing", source, valuesRedacted: true }; + } + if (status === "incomplete") return { phase: "ui", code: "replay_incomplete", source, valuesRedacted: true }; + if (status === "error") return { phase: "ui", code: "replay_error", source, valuesRedacted: true }; + if (status === "closed") return { phase: "client", code: "stream_closed", source, valuesRedacted: true }; + return { phase: "ui", code: "replay_" + status.replace(/[^a-z0-9_-]+/giu, "_"), source, valuesRedacted: true }; +} + async function validateWorkbenchKafkaDebugReplay(command) { const config = workbenchKafkaDebugReplay; if (!config || config.enabled !== true) throw new Error("validateWorkbenchKafkaDebugReplay is not enabled by the owning YAML"); const reportDir = path.join(stateDir, "artifacts", "workbench-kafka-debug-replay", safeId(command.id)); const report = { ok: false, - contract: "web-probe-workbench-kafka-debug-replay-v1", + contract: "web-probe-workbench-kafka-debug-replay-v2", commandId: command.id, - expected: { topic: config.topic, groupPrefix: config.groupPrefix, completionTimeoutMs: config.completionTimeoutMs, valuesRedacted: true }, + expected: { + topic: config.topic, + groupPrefix: config.groupPrefix, + productEventsPath: config.productEventsPath, + debugEventsPath: config.debugEventsPath, + completionTimeoutMs: config.completionTimeoutMs, + requestObservationQuietMs: config.requestObservationQuietMs, + valuesRedacted: true + }, origin, startedAt: new Date().toISOString(), - valuesRedacted: true, + valuesRedacted: true }; const requests = []; + const productEventSourceRequests = []; let requestPage = null; + let requestPhase = "setup"; const requestListener = (request) => { let url; try { url = new URL(request.url()); } catch { return; } - if (url.pathname !== "/v1/workbench/debug/kafka-sse/events") return; + if (url.pathname === config.productEventsPath) { + productEventSourceRequests.push({ method: request.method(), path: url.pathname, phase: requestPhase, valuesRedacted: true }); + return; + } + if (url.pathname !== config.debugEventsPath) return; requests.push({ method: request.method(), path: url.pathname, stream: url.searchParams.get("stream"), traceId: url.searchParams.get("traceId"), fromBeginning: url.searchParams.get("fromBeginning"), - valuesRedacted: true, + phase: requestPhase, + valuesRedacted: true }); }; try { @@ -66,6 +169,8 @@ async function validateWorkbenchKafkaDebugReplay(command) { const sessionId = snapshot?.activeSessionId || snapshot?.routeSessionId || routeSessionIdFromUrl(currentPageUrl()); if (!sessionId) throw new Error("validateWorkbenchKafkaDebugReplay requires an existing Workbench session"); + requestPage = page; + requestPage.on("request", requestListener); const toggle = page.locator('[data-testid="workbench-kafka-debug-toggle"]').first(); const toggleVisible = await toggle.waitFor({ state: "visible", timeout: 10000 }).then(() => true).catch(() => false); if (!toggleVisible) throw new Error("Workbench Kafka debug toggle is not visible; verify the owning YAML capability and admin session"); @@ -88,45 +193,91 @@ async function validateWorkbenchKafkaDebugReplay(command) { if (!/^trc_[A-Za-z0-9_-]+$/u.test(before.traceId || "")) throw new Error("Workbench Kafka debug replay requires the current traceId"); if (await replayButton.isDisabled()) throw new Error("Workbench Kafka debug replay button is disabled for traceId " + before.traceId); - requestPage = page; - requestPage.on("request", requestListener); + requestPhase = "debug-replay"; await replayButton.click({ timeout: 10000 }); const terminalStatus = await workbenchKafkaDebugReplayWaitForTerminal(panel, config.completionTimeoutMs); - if (terminalStatus !== "completed") throw new Error("Workbench Kafka debug replay ended with status " + terminalStatus); const after = await workbenchKafkaDebugReplayPanelSnapshot(panel, countsNode); + Object.assign(report, { + sessionId, + traceId: after.traceId, + topic: after.topic, + groupId: after.groupId, + terminalStatus, + phase: after.typedOutcome.phase, + code: after.typedOutcome.code, + typedOutcome: after.typedOutcome, + layerCounts: after.layerCounts, + legacyCounts: after.counts, + valuesRedacted: true + }); + const traceTimeline = await workbenchKafkaDebugReplayTraceTimelineSnapshot(panel); + report.traceTimeline = traceTimeline; + const rawHwlabEventWindow = await workbenchKafkaDebugReplayInspectRawWindow({ + panel, + config, + pathBefore: currentPath, + productEventSourceRequests, + setRequestPhase: (value) => { requestPhase = value; } + }); + report.rawHwlabEventWindow = rawHwlabEventWindow; const matchingRequest = requests.find((item) => item.stream === "hwlab-debug" && item.traceId === before.traceId && item.fromBeginning === "true") || null; - if (!matchingRequest) throw new Error("Workbench Kafka debug replay did not issue the expected trace-scoped fromBeginning request"); - if (after.traceId !== before.traceId) throw new Error("Workbench Kafka debug replay traceId changed during replay"); - if (after.topic !== config.topic) throw new Error("Workbench Kafka debug replay topic mismatch: " + (after.topic || "missing")); + const replayEvidence = { + sessionId, + traceId: after.traceId, + topic: after.topic, + groupId: after.groupId, + terminalStatus, + phase: after.typedOutcome.phase, + code: after.typedOutcome.code, + typedOutcome: after.typedOutcome, + layerCounts: after.layerCounts, + legacyCounts: after.counts, + traceTimeline, + rawHwlabEventWindow, + productEventSource: { + path: config.productEventsPath, + additionalRequestCountDuringTabSwitch: rawHwlabEventWindow.productEventSourceRequestCount, + requests: productEventSourceRequests.slice(-10), + valuesRedacted: true + }, + valuesRedacted: true + }; + Object.assign(report, replayEvidence); + + if (terminalStatus !== "completed") throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay ended with status " + terminalStatus, replayEvidence); + if (!matchingRequest) throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay did not issue the expected trace-scoped fromBeginning request", replayEvidence); + if (after.traceId !== before.traceId) throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay traceId changed during replay", replayEvidence); + if (after.topic !== config.topic) throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay topic mismatch: " + (after.topic || "missing"), replayEvidence); if (!after.groupId || !after.groupId.startsWith(config.groupPrefix + "-")) { - throw new Error("Workbench Kafka debug replay groupId does not use the YAML group prefix"); + throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay groupId does not use the YAML group prefix", replayEvidence); } if (!after.deliveryText.includes("debug-replay") || !after.deliveryText.includes("fromBeginning=true")) { - throw new Error("Workbench Kafka debug replay delivery contract is missing"); + throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay delivery contract is missing", replayEvidence); } if (!after.counts || after.counts.receivedCount <= 0 || after.counts.appliedCount <= 0) { - throw new Error("Workbench Kafka debug replay received/applied counts must both be greater than zero"); + throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay received/applied counts must both be greater than zero", replayEvidence); } + if (after.typedOutcome.phase !== "terminal" || after.typedOutcome.code !== "terminal_complete") { + throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay typed terminal contract is not terminal_complete", replayEvidence); + } + workbenchKafkaDebugReplayValidateLayerCounts(after.layerCounts, replayEvidence); + workbenchKafkaDebugReplayValidateTraceTimeline(traceTimeline, replayEvidence); + workbenchKafkaDebugReplayValidateRawWindow(rawHwlabEventWindow, replayEvidence); const screenshot = await captureScreenshot("workbench-kafka-debug-replay-" + safeId(command.id)); Object.assign(report, { ok: true, status: "passed", completedAt: new Date().toISOString(), - sessionId, - traceId: after.traceId, - topic: after.topic, - groupId: after.groupId, groupPrefix: config.groupPrefix, receivedCount: after.counts.receivedCount, appliedCount: after.counts.appliedCount, - terminalStatus, toggle: { available: true, enabled: true, checkedBefore: toggleCheckedBefore, checkedAfter: await toggle.isChecked(), valuesRedacted: true }, clear: cleared, request: matchingRequest, screenshot: { path: screenshot.path, sha256: screenshot.sha256, valuesRedacted: true }, controlRecovery, - valuesRedacted: true, + valuesRedacted: true }); const reportArtifact = await workbenchKafkaDebugReplayWriteReport(reportDir, report); report.reportPath = reportArtifact.path; @@ -136,12 +287,17 @@ async function validateWorkbenchKafkaDebugReplay(command) { traceId: report.traceId, topic: report.topic, groupId: report.groupId, + phase: report.phase, + code: report.code, receivedCount: report.receivedCount, appliedCount: report.appliedCount, + layerCounts: report.layerCounts, + traceTimeline: report.traceTimeline, + rawHwlabEventWindow: report.rawHwlabEventWindow, reportPath: report.reportPath, reportSha256: report.reportSha256, screenshot: report.screenshot, - valuesRedacted: true, + valuesRedacted: true })); return { ok: true, @@ -151,13 +307,19 @@ async function validateWorkbenchKafkaDebugReplay(command) { topic: report.topic, groupId: report.groupId, groupPrefix: report.groupPrefix, + phase: report.phase, + code: report.code, receivedCount: report.receivedCount, appliedCount: report.appliedCount, + layerCounts: report.layerCounts, + traceTimeline: report.traceTimeline, + rawHwlabEventWindow: report.rawHwlabEventWindow, + productEventSource: report.productEventSource, terminalStatus, reportPath: report.reportPath, reportSha256: report.reportSha256, screenshot: report.screenshot, - valuesRedacted: true, + valuesRedacted: true }; } catch (error) { Object.assign(report, { @@ -166,15 +328,26 @@ async function validateWorkbenchKafkaDebugReplay(command) { completedAt: new Date().toISOString(), failure: errorSummary(error), requests, - valuesRedacted: true, + valuesRedacted: true }); const reportArtifact = await workbenchKafkaDebugReplayWriteReport(reportDir, report).catch(() => null); const wrapped = error instanceof Error ? error : new Error(String(error)); wrapped.details = { ...(wrapped.details || {}), + traceId: report.traceId || wrapped.details?.traceId || null, + topic: report.topic || wrapped.details?.topic || null, + groupId: report.groupId || wrapped.details?.groupId || null, + terminalStatus: report.terminalStatus || wrapped.details?.terminalStatus || null, + receivedCount: report.receivedCount ?? report.legacyCounts?.receivedCount ?? wrapped.details?.receivedCount ?? null, + appliedCount: report.appliedCount ?? report.legacyCounts?.appliedCount ?? wrapped.details?.appliedCount ?? null, + phase: report.phase || wrapped.details?.phase || null, + code: report.code || wrapped.details?.code || null, + layerCounts: report.layerCounts || wrapped.details?.layerCounts || null, + traceTimeline: report.traceTimeline || wrapped.details?.traceTimeline || null, + rawHwlabEventWindow: report.rawHwlabEventWindow || wrapped.details?.rawHwlabEventWindow || null, reportPath: reportArtifact?.path || null, reportSha256: reportArtifact?.sha256 || null, - valuesRedacted: true, + valuesRedacted: true }; throw wrapped; } finally { @@ -206,6 +379,15 @@ async function workbenchKafkaDebugReplayWaitForTerminal(panel, timeoutMs) { throw new Error("Workbench Kafka debug replay did not reach a terminal panel status within " + timeoutMs + "ms"); } +async function workbenchKafkaDebugReplayWaitForTabSelected(tab, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if (await tab.getAttribute("aria-selected") === "true") return true; + await sleep(50); + } + return false; +} + async function workbenchKafkaDebugReplayPanelSnapshot(panel, countsNode) { const contract = await panel.locator('[data-testid="workbench-isolated-debug-contract"]').evaluate((root) => { const values = {}; @@ -216,17 +398,194 @@ async function workbenchKafkaDebugReplayPanelSnapshot(panel, countsNode) { } return { values, text: root.textContent?.replace(/\s+/gu, " ").trim() || "" }; }); + const status = await panel.getAttribute("data-replay-status"); + const layerNode = panel.locator('[data-testid="workbench-kafka-debug-layer-counts"]').first(); + const layerText = await layerNode.count() > 0 ? await layerNode.textContent() : ""; + const errorNode = panel.locator(".isolated-debug-error").first(); + const errorText = await errorNode.count() > 0 ? String(await errorNode.textContent() || "").trim() : ""; + const layerCounts = workbenchKafkaDebugReplayLayerCounts(layerText); return { - status: await panel.getAttribute("data-replay-status"), + status, traceId: contract.values.traceId || null, topic: contract.values.topic || null, groupId: contract.values.group || null, deliveryText: contract.text, counts: workbenchKafkaDebugReplayCounts(await countsNode.textContent()), - valuesRedacted: true, + layerCounts, + error: errorText ? { textHash: sha256Text(errorText), textPreview: truncate(errorText, 160), textBytes: Buffer.byteLength(errorText), valuesRedacted: true } : null, + typedOutcome: workbenchKafkaDebugReplayTypedOutcome({ status, errorText, layerCounts }), + valuesRedacted: true }; } +async function workbenchKafkaDebugReplayTraceTimelineSnapshot(panel) { + return panel.evaluate((root) => { + const rows = Array.from(root.querySelectorAll('[data-testid="trace-render-row"]')); + const authorityRows = rows.filter((row) => row.hasAttribute("data-event-seq-authority")); + const sourceRows = authorityRows.filter((row) => row.getAttribute("data-event-seq-authority") === "source"); + const projectedRows = authorityRows.filter((row) => row.getAttribute("data-event-seq-authority") === "projected"); + const readableSourceRows = sourceRows.filter((row) => Boolean(row.textContent?.replace(/\s+/gu, " ").trim())); + return { + contract: authorityRows.length > 0 ? "trace-sequence-authority-v1" : "legacy-ui", + authorityAvailable: authorityRows.length > 0, + rowCount: rows.length, + sourceAuthorityRowCount: sourceRows.length, + projectedAuthorityRowCount: projectedRows.length, + readableSourceRowCount: readableSourceRows.length, + samples: readableSourceRows.slice(0, 8).map((row) => ({ + seq: row.getAttribute("data-event-seq"), + authority: row.getAttribute("data-event-seq-authority"), + kind: row.getAttribute("data-event-kind"), + status: row.getAttribute("data-event-status"), + terminal: row.getAttribute("data-terminal") === "true", + textBytes: new TextEncoder().encode(row.textContent?.replace(/\s+/gu, " ").trim() || "").byteLength + })), + valuesRedacted: true + }; + }); +} + +async function workbenchKafkaDebugReplayInspectRawWindow(input) { + const rawTabLocator = input.panel.locator('[data-testid="workbench-kafka-debug-tab-raw"]'); + const rawTabCount = await rawTabLocator.count(); + if (rawTabCount === 0) { + return { + available: false, + contract: "legacy-ui", + reason: "raw-hwlab-event-tab-not-present", + productEventsPath: input.config.productEventsPath, + productEventSourceRequestCount: 0, + valuesRedacted: true + }; + } + if (rawTabCount !== 1) throw new Error("Workbench raw HWLAB Event tab count is " + rawTabCount + ", expected 1"); + const traceTabLocator = input.panel.locator('[data-testid="workbench-kafka-debug-tab-trace"]'); + const traceTabCount = await traceTabLocator.count(); + if (traceTabCount !== 1) throw new Error("Workbench Trace debug tab count is " + traceTabCount + ", expected 1"); + const rawTab = rawTabLocator.first(); + const traceTab = traceTabLocator.first(); + const requestStart = input.productEventSourceRequests.length; + const pageUrlBefore = currentPageUrl(); + input.setRequestPhase("raw-tab-switch"); + await rawTab.click({ timeout: 10000 }); + if (!await workbenchKafkaDebugReplayWaitForTabSelected(rawTab, 5000)) throw new Error("Workbench raw HWLAB Event tab did not become selected"); + const rawCountsNode = input.panel.locator('[data-testid="workbench-raw-hwlab-counts"]').first(); + const rawTextNode = input.panel.locator('[data-testid="workbench-raw-hwlab-text"]').first(); + const rawContractNode = input.panel.locator('[data-testid="workbench-raw-hwlab-contract"]').first(); + await rawCountsNode.waitFor({ state: "visible", timeout: 5000 }); + await rawTextNode.waitFor({ state: "visible", timeout: 5000 }); + await rawContractNode.waitFor({ state: "visible", timeout: 5000 }); + await sleep(input.config.requestObservationQuietMs); + const counts = workbenchKafkaDebugReplayRawCounts(await rawCountsNode.textContent()); + const rawText = await rawTextNode.inputValue(); + const contractText = String(await rawContractNode.textContent() || "").replace(/\s+/gu, " ").trim(); + input.setRequestPhase("trace-tab-restore"); + await traceTab.click({ timeout: 10000 }); + if (!await workbenchKafkaDebugReplayWaitForTabSelected(traceTab, 5000)) throw new Error("Workbench Trace debug tab did not restore after raw inspection"); + await sleep(input.config.requestObservationQuietMs); + input.setRequestPhase("complete"); + const pageUrlAfter = currentPageUrl(); + const productRequests = input.productEventSourceRequests.slice(requestStart); + return { + available: true, + contract: "raw-hwlab-event-window-v1", + pageId, + samePage: safeUrlPath(pageUrlBefore) === input.pathBefore && safeUrlPath(pageUrlAfter) === input.pathBefore, + pathBefore: input.pathBefore, + pathAfter: safeUrlPath(pageUrlAfter), + productEventsPath: input.config.productEventsPath, + productEventSourceRequestCount: productRequests.length, + productEventSourceRequests: productRequests.slice(-10), + sourceContractPresent: contractText.includes("EventSource") && contractText.includes("hwlab.event.v1"), + unfilteredContractPresent: contractText.includes("no client event filter"), + counts, + textBytes: Buffer.byteLength(rawText), + textHash: sha256Text(rawText), + textPresent: rawText.length > 0, + valuesRedacted: true + }; +} + +function workbenchKafkaDebugReplayValidateLayerCounts(layerCounts, evidence) { + if (!layerCounts) { + throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay typed server/client count contract is missing", evidence); + } + const values = [ + layerCounts.server.scanned, + layerCounts.server.matched, + layerCounts.server.delivered, + layerCounts.client.received, + layerCounts.client.decoded, + layerCounts.client.applied + ]; + if (values.some((value) => !Number.isInteger(value) || value <= 0)) { + throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay typed server/client counts must all be greater than zero", evidence); + } + if (layerCounts.server.delivered !== layerCounts.client.received) { + throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay server delivered and client received counts differ", evidence); + } + if (layerCounts.server.scanned < layerCounts.server.matched || layerCounts.server.matched !== layerCounts.server.delivered) { + throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay server scanned/matched/delivered counts are inconsistent", evidence); + } + if (layerCounts.client.received !== layerCounts.client.decoded || layerCounts.client.decoded !== layerCounts.client.applied) { + throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay client received/decoded/applied counts differ", evidence); + } + if (evidence?.legacyCounts && (evidence.legacyCounts.receivedCount !== layerCounts.client.received || evidence.legacyCounts.appliedCount !== layerCounts.client.applied)) { + throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay legacy reducer counts differ from the typed client layer", evidence); + } +} + +function workbenchKafkaDebugReplayValidateTraceTimeline(traceTimeline, evidence) { + if (!traceTimeline.authorityAvailable) { + throw workbenchKafkaDebugReplayEvidenceError("Workbench Trace timeline source-authority contract is missing", evidence); + } + if (traceTimeline.sourceAuthorityRowCount <= 0 || traceTimeline.readableSourceRowCount <= 0) { + throw workbenchKafkaDebugReplayEvidenceError("Workbench Trace timeline did not expose a readable source-authority row", evidence); + } +} + +function workbenchKafkaDebugReplayValidateRawWindow(rawWindow, evidence) { + if (!rawWindow.available) { + throw workbenchKafkaDebugReplayEvidenceError("Workbench raw HWLAB Event window contract is missing", evidence); + } + if (!rawWindow.samePage) throw workbenchKafkaDebugReplayEvidenceError("Workbench raw HWLAB Event inspection navigated away from the original page", evidence); + if (!rawWindow.sourceContractPresent) throw workbenchKafkaDebugReplayEvidenceError("Workbench raw HWLAB Event window does not declare the existing product EventSource contract", evidence); + if (!rawWindow.unfilteredContractPresent) throw workbenchKafkaDebugReplayEvidenceError("Workbench raw HWLAB Event window does not declare the unfiltered ingress contract", evidence); + if (!rawWindow.counts) throw workbenchKafkaDebugReplayEvidenceError("Workbench raw HWLAB Event counts are unreadable", evidence); + const counts = rawWindow.counts; + if ([counts.received, counts.retained, counts.decoded, counts.rejected, counts.evicted, counts.bytes].some((value) => !Number.isInteger(value) || value < 0)) { + throw workbenchKafkaDebugReplayEvidenceError("Workbench raw HWLAB Event counts are incomplete", evidence); + } + if (counts.received !== counts.decoded + counts.rejected || counts.received !== counts.retained + counts.evicted) { + throw workbenchKafkaDebugReplayEvidenceError("Workbench raw HWLAB Event bounded-buffer counts are inconsistent", evidence); + } + if (counts.received <= 0 || counts.retained <= 0 || counts.bytes <= 0 || rawWindow.textPresent !== true || rawWindow.textBytes <= 0) { + throw workbenchKafkaDebugReplayEvidenceError("Workbench raw HWLAB Event window did not retain a readable product SSE frame", evidence); + } + if (rawWindow.productEventSourceRequestCount !== 0) { + throw workbenchKafkaDebugReplayEvidenceError("Workbench raw HWLAB Event tab opened an additional product EventSource", evidence); + } +} + +function workbenchKafkaDebugReplayEvidenceError(message, evidence) { + const error = new Error(message); + error.details = { + traceId: evidence?.traceId || null, + topic: evidence?.topic || null, + groupId: evidence?.groupId || null, + terminalStatus: evidence?.terminalStatus || null, + receivedCount: evidence?.legacyCounts?.receivedCount ?? null, + appliedCount: evidence?.legacyCounts?.appliedCount ?? null, + phase: evidence?.phase || null, + code: evidence?.code || null, + layerCounts: evidence?.layerCounts || null, + traceTimeline: evidence?.traceTimeline || null, + rawHwlabEventWindow: evidence?.rawHwlabEventWindow || null, + valuesRedacted: true + }; + return error; +} + async function workbenchKafkaDebugReplayWriteReport(reportDir, report) { await mkdir(reportDir, { recursive: true, mode: 0o700 }); const file = path.join(reportDir, "report.json"); diff --git a/scripts/src/hwlab-node/web-observe-render.test.ts b/scripts/src/hwlab-node/web-observe-render.test.ts index c99814b2..ab9e78e8 100644 --- a/scripts/src/hwlab-node/web-observe-render.test.ts +++ b/scripts/src/hwlab-node/web-observe-render.test.ts @@ -90,8 +90,26 @@ test("observe status renderer exposes bounded Workbench Kafka debug replay evide traceId: "trc_fixture", topic: "hwlab.event.debug.v1", groupId: "hwlab-v03-workbench-isolated-debug-123-fixture", + phase: "terminal", + code: "terminal_complete", receivedCount: 35, appliedCount: 35, + layerCounts: { + server: { scanned: 35, matched: 35, delivered: 35 }, + client: { received: 35, decoded: 35, applied: 35 }, + }, + traceTimeline: { + contract: "trace-sequence-authority-v1", + sourceAuthorityRowCount: 35, + readableSourceRowCount: 35, + }, + rawHwlabEventWindow: { + available: true, + samePage: true, + unfilteredContractPresent: true, + productEventSourceRequestCount: 0, + counts: { received: 35, retained: 30, rejected: 0, evicted: 5, bytes: 8192 }, + }, terminalStatus: "completed", reportSha256: "sha256:report", screenshot: { sha256: "sha256:image" }, @@ -105,7 +123,10 @@ test("observe status renderer exposes bounded Workbench Kafka debug replay evide const text = String(rendered.renderedText); assert.match(text, /Workbench Kafka debug replay:/u); assert.match(text, /trc_fixture.*hwlab\.event\.debug\.v1/u); + assert.match(text, /terminal.*terminal_complete/u); assert.match(text, /35.*35.*completed.*sha256:image/u); + assert.match(text, /SERVER_SCANNED.*CLIENT_APPLIED/u); + assert.match(text, /trace-sequence-authority-v1.*35.*35.*true.*true.*true.*35.*30.*0.*5.*8192.*0/u); }); test("web-probe script render warns to promote repeated scripts into commands", () => { diff --git a/scripts/src/hwlab-node/web-observe-scripts.test.ts b/scripts/src/hwlab-node/web-observe-scripts.test.ts index c5346fb0..4bf86479 100644 --- a/scripts/src/hwlab-node/web-observe-scripts.test.ts +++ b/scripts/src/hwlab-node/web-observe-scripts.test.ts @@ -86,8 +86,26 @@ test("observe status preserves bounded Workbench Kafka debug replay evidence", a topic: "hwlab.event.debug.v1", groupId: "hwlab-v03-workbench-isolated-debug-123-fixture", groupPrefix: "hwlab-v03-workbench-isolated-debug", + phase: "terminal", + code: "terminal_complete", receivedCount: 35, appliedCount: 35, + layerCounts: { + server: { scanned: 35, matched: 35, delivered: 35 }, + client: { received: 35, decoded: 35, applied: 35 }, + }, + traceTimeline: { + contract: "trace-sequence-authority-v1", + sourceAuthorityRowCount: 35, + readableSourceRowCount: 35, + }, + rawHwlabEventWindow: { + available: true, + samePage: true, + unfilteredContractPresent: true, + productEventSourceRequestCount: 0, + counts: { received: 35, retained: 30, rejected: 0, evicted: 5, bytes: 8192 }, + }, terminalStatus: "completed", reportPath: "/tmp/report.json", reportSha256: "sha256:report", @@ -102,5 +120,10 @@ test("observe status preserves bounded Workbench Kafka debug replay evidence", a assert.equal(status.exactCommand.result.groupPrefix, "hwlab-v03-workbench-isolated-debug"); assert.equal(status.exactCommand.result.receivedCount, 35); assert.equal(status.exactCommand.result.appliedCount, 35); + assert.equal(status.exactCommand.result.phase, "terminal"); + assert.equal(status.exactCommand.result.code, "terminal_complete"); + assert.equal(status.exactCommand.result.layerCounts.server.scanned, 35); + assert.equal(status.exactCommand.result.traceTimeline.sourceAuthorityRowCount, 35); + assert.equal(status.exactCommand.result.rawHwlabEventWindow.counts.retained, 30); assert.equal(status.exactCommand.result.screenshot.sha256, "sha256:image"); }); diff --git a/scripts/src/hwlab-node/web-observe-scripts.ts b/scripts/src/hwlab-node/web-observe-scripts.ts index 20ae098a..931580af 100644 --- a/scripts/src/hwlab-node/web-observe-scripts.ts +++ b/scripts/src/hwlab-node/web-observe-scripts.ts @@ -60,6 +60,7 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string, const compactSample=(item)=>({seq:item.seq,ts:item.ts,path:item.path,routeSessionId:item.routeSessionId||null,activeSessionId:item.activeSessionId||null,messageCount:Array.isArray(item.messages)?item.messages.length:0,traceRowCount:Array.isArray(item.traceRows)?item.traceRows.length:0,loadingCount:Array.isArray(item.loadings)?item.loadings.length:0,loadingOwners:Array.isArray(item.loadings)?Array.from(new Set(item.loadings.map((loading)=>loading.ownerLabel||loading.ownerKind||loading.ownerKey||'loading'))).slice(0,4):[],sessionRail:item.sessionRail?{visibleCount:item.sessionRail.visibleCount??null,fallbackTitleCount:item.sessionRail.fallbackTitleCount??null,fallbackTitleRatio:item.sessionRail.fallbackTitleRatio??null,fallbackItems:Array.isArray(item.sessionRail.fallbackItems)?item.sessionRail.fallbackItems.slice(0,4).map((fallback)=>({sessionIdPrefix:fallback.sessionIdPrefix??null,titlePreview:fallback.titlePreview??null,active:fallback.active===true})):[]}:null}); const compactNetwork=(item)=>({ts:item.ts,type:item.type,method:item.method,url:short(item.url),status:item.status||null,failure:item.failure?short(item.failure):null}); const commandDir=(name)=>path.join(dir,'commands',name); + const compactReplayEvidence=(value)=>{const item=value&&typeof value==='object'?value:null;if(!item)return{};const layers=item.layerCounts&&typeof item.layerCounts==='object'?item.layerCounts:null;const server=layers&&layers.server&&typeof layers.server==='object'?layers.server:null;const client=layers&&layers.client&&typeof layers.client==='object'?layers.client:null;const trace=item.traceTimeline&&typeof item.traceTimeline==='object'?item.traceTimeline:null;const raw=item.rawHwlabEventWindow&&typeof item.rawHwlabEventWindow==='object'?item.rawHwlabEventWindow:null;const rawCounts=raw&&raw.counts&&typeof raw.counts==='object'?raw.counts:null;return{phase:item.phase||null,code:item.code||null,layerCounts:layers?{server:server?{scanned:server.scanned??null,matched:server.matched??null,delivered:server.delivered??null}:null,client:client?{received:client.received??null,decoded:client.decoded??null,applied:client.applied??null}:null,valuesRedacted:true}:null,traceTimeline:trace?{contract:trace.contract||null,authorityAvailable:trace.authorityAvailable??null,rowCount:trace.rowCount??null,sourceAuthorityRowCount:trace.sourceAuthorityRowCount??null,projectedAuthorityRowCount:trace.projectedAuthorityRowCount??null,readableSourceRowCount:trace.readableSourceRowCount??null,valuesRedacted:true}:null,rawHwlabEventWindow:raw?{available:raw.available??null,contract:raw.contract||null,reason:raw.reason||null,samePage:raw.samePage??null,productEventsPath:raw.productEventsPath||null,productEventSourceRequestCount:raw.productEventSourceRequestCount??null,sourceContractPresent:raw.sourceContractPresent??null,unfilteredContractPresent:raw.unfilteredContractPresent??null,textBytes:raw.textBytes??null,textHash:raw.textHash||null,textPresent:raw.textPresent??null,counts:rawCounts?{received:rawCounts.received??null,retained:rawCounts.retained??null,decoded:rawCounts.decoded??null,rejected:rawCounts.rejected??null,evicted:rawCounts.evicted??null,bytes:rawCounts.bytes??null}:null,valuesRedacted:true}:null}}; const commandSummary=(name)=>{ const root=commandDir(name); let entries=[]; try{entries=fs.readdirSync(root).filter((item)=>item.endsWith('.json')).sort();}catch{} @@ -74,7 +75,7 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string, if(!parsed)continue; const result=parsed.result&&typeof parsed.result==='object'?parsed.result:null; const error=parsed.error&&typeof parsed.error==='object'?parsed.error:null; - return {commandId:exactCommandId,phase:bucket,ok:parsed.ok??null,type:parsed.type||null,createdAt:parsed.createdAt||null,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,profile:result.profile||null,sessionId:result.sessionId||null,traceId:result.traceId||null,traceIds:Array.isArray(result.traceIds)?result.traceIds.slice(0,4):[],runIds:Array.isArray(result.runIds)?result.runIds.slice(0,4):[],commandIds:Array.isArray(result.commandIds)?result.commandIds.slice(0,4):[],topic:result.topic||null,groupId:result.groupId||null,groupPrefix:result.groupPrefix||null,receivedCount:result.receivedCount??null,appliedCount:result.appliedCount??null,terminalStatus:result.terminalStatus||null,warmRunnerReused:result.warmRunnerReused??null,forbiddenRequestCount:result.forbiddenRequestCount??null,replayedEventCount:result.replayedEventCount??null,reportPath:result.reportPath||null,reportSha256:result.reportSha256||null,screenshot:result.screenshot&&typeof result.screenshot==='object'?{path:result.screenshot.path||null,sha256:result.screenshot.sha256||null,valuesRedacted:true}:null,valuesRedacted:true}:null,error:error?{name:error.name||null,message:short(error.message,200),details:error.details&&typeof error.details==='object'?{profile:error.details.profile||null,reportPath:error.details.reportPath||null,reportSha256:error.details.reportSha256||null,valuesRedacted:true}:null}:null,valuesRedacted:true}; + return {commandId:exactCommandId,phase:bucket,ok:parsed.ok??null,type:parsed.type||null,createdAt:parsed.createdAt||null,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,profile:result.profile||null,sessionId:result.sessionId||null,traceId:result.traceId||null,traceIds:Array.isArray(result.traceIds)?result.traceIds.slice(0,4):[],runIds:Array.isArray(result.runIds)?result.runIds.slice(0,4):[],commandIds:Array.isArray(result.commandIds)?result.commandIds.slice(0,4):[],topic:result.topic||null,groupId:result.groupId||null,groupPrefix:result.groupPrefix||null,receivedCount:result.receivedCount??null,appliedCount:result.appliedCount??null,terminalStatus:result.terminalStatus||null,...compactReplayEvidence(result),warmRunnerReused:result.warmRunnerReused??null,forbiddenRequestCount:result.forbiddenRequestCount??null,replayedEventCount:result.replayedEventCount??null,reportPath:result.reportPath||null,reportSha256:result.reportSha256||null,screenshot:result.screenshot&&typeof result.screenshot==='object'?{path:result.screenshot.path||null,sha256:result.screenshot.sha256||null,valuesRedacted:true}:null,valuesRedacted:true}:null,error:error?{name:error.name||null,message:short(error.message,200),details:error.details&&typeof error.details==='object'?{profile:error.details.profile||null,...compactReplayEvidence(error.details),traceId:error.details.traceId||null,topic:error.details.topic||null,groupId:error.details.groupId||null,receivedCount:error.details.receivedCount??null,appliedCount:error.details.appliedCount??null,terminalStatus:error.details.terminalStatus||null,reportPath:error.details.reportPath||null,reportSha256:error.details.reportSha256||null,valuesRedacted:true}:null}:null,valuesRedacted:true}; } return {commandId:exactCommandId,phase:'not-found',ok:false,valuesRedacted:true}; }; diff --git a/scripts/src/hwlab-node/web-probe-observe-actions.test.ts b/scripts/src/hwlab-node/web-probe-observe-actions.test.ts index d1a27d00..6ab60620 100644 --- a/scripts/src/hwlab-node/web-probe-observe-actions.test.ts +++ b/scripts/src/hwlab-node/web-probe-observe-actions.test.ts @@ -35,6 +35,9 @@ test("Workbench Kafka debug replay runner contract is derived from owning YAML", topic: "hwlab.event.debug.v1", groupPrefix: "hwlab-v03-workbench-isolated-debug", completionTimeoutMs: 30_000, + productEventsPath: "/v1/workbench/events", + debugEventsPath: "/v1/workbench/debug/kafka-sse/events", + requestObservationQuietMs: 3_000, valuesRedacted: true, }); }); diff --git a/scripts/src/hwlab-node/web-probe-observe-actions.ts b/scripts/src/hwlab-node/web-probe-observe-actions.ts index aeec6b89..85c3b8ed 100644 --- a/scripts/src/hwlab-node/web-probe-observe-actions.ts +++ b/scripts/src/hwlab-node/web-probe-observe-actions.ts @@ -51,7 +51,17 @@ export function nodeWebProbeRealtimeFanoutProfiles(spec: HwlabRuntimeLaneSpec): export function nodeWebProbeWorkbenchKafkaDebugReplay(spec: HwlabRuntimeLaneSpec): Record | null { const config = spec.webProbe?.workbenchKafkaDebugReplay; if (config === undefined) return null; - return { ...config, valuesRedacted: true }; + const pureKafkaLive = spec.webProbe?.realtimeFanoutProfiles?.["pure-kafka-live"]; + if (pureKafkaLive === undefined) { + throw new Error("workbenchKafkaDebugReplay requires the pure-kafka-live realtimeFanoutProfile in the owning YAML"); + } + return { + ...config, + productEventsPath: pureKafkaLive.productEventsPath, + debugEventsPath: pureKafkaLive.debugEventsPath, + requestObservationQuietMs: pureKafkaLive.reconnectQuietMs, + valuesRedacted: true, + }; } export function resolveWebObserveActionJson(