fix: 强化 Workbench Kafka 重放探针

This commit is contained in:
Codex
2026-07-10 15:58:51 +02:00
parent d69b436226
commit 6297a2ea14
10 changed files with 619 additions and 55 deletions
+6 -1
View File
@@ -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 <profile>` 时,command result 必须显示 `requestedProvider``providerSelection.selected``/v1/agent/chat` 202、traceId 和最终 `turn-summary`;只有 prompt 成功不够,必须确认浏览器控件实际选中目标 profile。
- Workbench 纯实时 fanout 的重复验收使用 `observe command --type validateRealtimeFanout --profile <yaml-profile> --provider <provider> --text <first> --second-text <second>`;它在 observer 后台维持两个独立 browser context 和三条 debug SSE,命令提交后按 `status <observer> --command-id <command>` 轮询,不得回退为长连接 `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 或单张截图下结论。
## 高频工作流
@@ -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 或一次性脚本替代该命令。
+66 -23
View File
@@ -41,6 +41,7 @@ function renderWebObserveStatusTable(value: Record<string, unknown>): 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<string, unknown> => item !== null).slice(-5) : [];
@@ -175,17 +176,12 @@ function renderWebObserveStatusTable(value: Record<string, unknown>): 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, unknown>): string {
return lines.join("\n");
}
function renderWorkbenchKafkaDebugReplayEvidence(
evidence: Record<string, unknown>,
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, unknown>): string {
const observerCommand = record(value.observerCommand);
const observer = record(value.observer);
@@ -241,6 +289,7 @@ function renderWebObserveCommandTable(value: Record<string, unknown>): 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, unknown>): 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 ? [
"",
@@ -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<string, unknown>;
workbenchKafkaDebugReplayCounts: (text: string) => Record<string, number> | null;
workbenchKafkaDebugReplayLayerCounts: (text: string) => Record<string, unknown> | null;
workbenchKafkaDebugReplayRawCounts: (text: string) => Record<string, unknown> | null;
workbenchKafkaDebugReplayTypedOutcome: (input: Record<string, unknown>) => Record<string, unknown>;
workbenchKafkaDebugReplayValidateLayerCounts: (counts: Record<string, unknown> | null, evidence: Record<string, unknown>) => void;
workbenchKafkaDebugReplayValidateTraceTimeline: (timeline: Record<string, unknown>, evidence: Record<string, unknown>) => void;
workbenchKafkaDebugReplayValidateRawWindow: (window: Record<string, unknown>, evidence: Record<string, unknown>) => 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();
@@ -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");
@@ -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", () => {
@@ -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");
});
@@ -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};
};
@@ -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,
});
});
@@ -51,7 +51,17 @@ export function nodeWebProbeRealtimeFanoutProfiles(spec: HwlabRuntimeLaneSpec):
export function nodeWebProbeWorkbenchKafkaDebugReplay(spec: HwlabRuntimeLaneSpec): Record<string, unknown> | 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(