Merge pull request #1711 from pikasTech/fix/2474-web-probe-trace-readability
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / unidesk-host- Success

fix: 增强 Workbench Trace 与隔离 Kafka 重放探针
This commit is contained in:
Lyon
2026-07-11 00:09:39 +08:00
committed by GitHub
21 changed files with 1378 additions and 16 deletions
+9
View File
@@ -47,6 +47,15 @@ description: UniDesk Web 开发与浏览器验证技能。用户处理 UniDesk/H
- 切换原始事件标签不得新建第二条产品 EventSource
- 旧 UI 缺少新 DOM 合同时保持明确的 `legacy-ui` 兼容证据。
- `observe analyze` 的 archive/history findings 可能包含旧样本或旧规则误判;关闭用户入口问题前同时核对最新 sample 字段、DOM 几何和截图。若 top-level findings、latest sample 和 archive red 冲突,说明冲突并补工具反馈 issue,不要只凭 archive red 或单张截图下结论。
- Workbench 产品 Trace 可读性验收使用 `observe command --type validateWorkbenchTraceReadability`
- 开关、运行卡片等待、展开、轮询和 DOM 稳定窗口全部来自独立 owning YAML
- 只在 `#conversation-list` 内定位当前产品 `WorkbenchMessageCard`
- 明确排除隔离 Kafka debug panel,禁止把隔离卡片行计入产品 Trace;
- 必须先命中新运行卡片并按合法 traceId 固定定位,禁止回退旧终态卡片;
- 在命令点击展开前记录 `openBefore` 与默认运行中可读性;
- 命令可展开卡片补充诊断,但不得把强制展开后的可读性冒充默认通过;
- 非窗口化 Trace 用行身份重叠验证保留;窗口化 Trace 用 event count 与 source sequence 边界前进验证;
- 同一命令等待终态并输出 report SHA 与 screenshot SHA,失败时仍保留已采集阶段证据。
## 高频工作流
@@ -15,3 +15,12 @@
- 原始事件标签必须复用同页产品 EventSource,切换标签不得新增产品 SSE 请求;
- 旧 UI 缺少新 DOM 合同时报告 `legacy-ui`,不伪造新合同;
- 禁止通过 URL/IP 或一次性脚本替代该命令。
- Workbench 产品 Trace 可读性规则:
- 使用 `observe command --type validateWorkbenchTraceReadability`
- 功能开关与全部等待/轮询/稳定窗口由独立 owning YAML 提供;
- 命令只读取 `#conversation-list` 内的当前产品卡片,排除隔离 debug panel
- 只接受新运行卡片,命中后按 traceId 固定定位,不回退旧终态卡片;
- 报告必须区分命令前默认展开状态和命令驱动展开状态;
- 默认运行中可读、source-authority 行和终态保留必须分别成立;
- 窗口化 Trace 以 event count 和 source sequence 边界前进替代可见行身份交集;
- 失败仍保留有界 DOM 诊断、report SHA 和 screenshot SHA。
+10
View File
@@ -593,6 +593,16 @@ templates:
topic: hwlab.event.debug.v1
groupPrefix: hwlab-v03-workbench-isolated-debug
completionTimeoutMs: 30000
workbenchTraceReadability:
enabled: true
runningCardTimeoutMs: 15000
disclosureActionTimeoutMs: 15000
disclosureOpenTimeoutMs: 10000
runningReadableTimeoutMs: 480000
terminalTimeoutMs: 480000
domStableQuietMs: 2000
domStableTimeoutMs: 6000
pollIntervalMs: 100
alertThresholds:
sameOriginApiSlowMs: 10000
partialApiSlowMs: 10000
+3
View File
@@ -64,6 +64,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type sendPrompt --text 'ping'",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateRealtimeFanout --profile pure-kafka-live --provider gpt.pika --text '<first-turn>' --second-text '<second-turn>'",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateWorkbenchKafkaDebugReplay",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateWorkbenchTraceReadability",
"bun scripts/cli.ts web-probe observe status webobs-xxxx --command-id cmd-xxxx",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type closeMdtodoSourceConfig",
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type selectMdtodoFile --filename 20260609_频率判断_用户反馈.md",
@@ -115,6 +116,8 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"Typed observe commands inherit the semantic origin selected by observe start and must not embed a replacement URL or IP.",
"validateRealtimeFanout is a YAML-profiled background command: it gates submit on two live product subscribers plus three Kafka debug consumers, validates two turns and disconnect/reconnect without replay, and is polled by exact command id.",
"validateWorkbenchKafkaDebugReplay 复用 observer 当前 Workbench session,以 owning YAML 中的 topic、group prefix 和超时验证隔离 Kafka 重放,并通过精确 command id 查询结果。",
"validateWorkbenchTraceReadability 只检查当前 Workbench 会话中的产品 Trace 卡片。",
"该命令排除隔离调试面板,并分别验证运行中默认展开、source authority 与终态保留。",
"collect views render bounded summaries from existing artifacts and do not create a second source of truth.",
"performanceCapture records an explicit bounded Chrome CPU profile and drains LongTask/LoAF/event-loop-gap artifacts; performance-summary reads existing artifacts only.",
"analyze is offline-only: it reads artifact JSONL and writes analysis/report.md plus analysis/report.json.",
+31
View File
@@ -155,6 +155,7 @@ export interface HwlabRuntimeWebProbeSpec {
readonly projectManagement?: HwlabRuntimeWebProbeProjectManagementSpec;
readonly realtimeFanoutProfiles?: Readonly<Record<string, HwlabRuntimeWebProbeRealtimeFanoutProfileSpec>>;
readonly workbenchKafkaDebugReplay?: HwlabRuntimeWebProbeWorkbenchKafkaDebugReplaySpec;
readonly workbenchTraceReadability?: HwlabRuntimeWebProbeWorkbenchTraceReadabilitySpec;
}
export interface HwlabRuntimeWebProbeWorkbenchKafkaDebugReplaySpec {
@@ -164,6 +165,18 @@ export interface HwlabRuntimeWebProbeWorkbenchKafkaDebugReplaySpec {
readonly completionTimeoutMs: number;
}
export interface HwlabRuntimeWebProbeWorkbenchTraceReadabilitySpec {
readonly enabled: boolean;
readonly runningCardTimeoutMs: number;
readonly disclosureActionTimeoutMs: number;
readonly disclosureOpenTimeoutMs: number;
readonly runningReadableTimeoutMs: number;
readonly terminalTimeoutMs: number;
readonly domStableQuietMs: number;
readonly domStableTimeoutMs: number;
readonly pollIntervalMs: number;
}
export interface HwlabRuntimeWebProbeRealtimeFanoutProfileSpec {
readonly subscriberCount: 2;
readonly debugStreams: readonly ("stdio" | "agentrun" | "hwlab")[];
@@ -1309,6 +1322,9 @@ function webProbeConfig(value: unknown, path: string): HwlabRuntimeWebProbeSpec
...(raw.workbenchKafkaDebugReplay === undefined
? {}
: { workbenchKafkaDebugReplay: webProbeWorkbenchKafkaDebugReplayConfig(raw.workbenchKafkaDebugReplay, `${path}.workbenchKafkaDebugReplay`) }),
...(raw.workbenchTraceReadability === undefined
? {}
: { workbenchTraceReadability: webProbeWorkbenchTraceReadabilityConfig(raw.workbenchTraceReadability, `${path}.workbenchTraceReadability`) }),
};
}
@@ -1332,6 +1348,21 @@ function webProbeWorkbenchKafkaDebugReplayConfig(value: unknown, path: string):
};
}
function webProbeWorkbenchTraceReadabilityConfig(value: unknown, path: string): HwlabRuntimeWebProbeWorkbenchTraceReadabilitySpec {
const raw = asRecord(value, path);
return {
enabled: booleanField(raw, "enabled", path),
runningCardTimeoutMs: numberField(raw, "runningCardTimeoutMs", path),
disclosureActionTimeoutMs: numberField(raw, "disclosureActionTimeoutMs", path),
disclosureOpenTimeoutMs: numberField(raw, "disclosureOpenTimeoutMs", path),
runningReadableTimeoutMs: numberField(raw, "runningReadableTimeoutMs", path),
terminalTimeoutMs: numberField(raw, "terminalTimeoutMs", path),
domStableQuietMs: numberField(raw, "domStableQuietMs", path),
domStableTimeoutMs: numberField(raw, "domStableTimeoutMs", path),
pollIntervalMs: numberField(raw, "pollIntervalMs", path),
};
}
function webProbeOriginsConfig(value: unknown, path: string): HwlabRuntimeWebProbeOriginsSpec {
const raw = asRecord(value, path);
for (const key of Object.keys(raw)) {
+92 -9
View File
@@ -38,9 +38,9 @@ function renderWebObserveStatusTable(value: Record<string, unknown>): string {
const diagnostics = record(observer?.diagnostics) ?? record(value.diagnostics);
const commands = record(observer?.commands);
const exactCommand = record(observer?.exactCommand);
const exactResult = record(exactCommand?.result);
const exactResult = nullableRecord(exactCommand?.result);
const exactError = record(exactCommand?.error);
const exactErrorDetails = record(exactError?.details);
const exactErrorDetails = nullableRecord(exactError?.details);
const exactReplayEvidence = exactResult ?? exactErrorDetails;
const tails = record(observer?.tails);
const result = record(value.result);
@@ -170,9 +170,9 @@ function renderWebObserveStatusTable(value: Record<string, unknown>): string {
exactCommand.type,
exactCommand.phase,
exactCommand.ok,
exactResult.status,
webObserveShort(webObserveText(exactResult.reportSha256 ?? exactErrorDetails.reportSha256), 32),
webObserveShort(webObserveText(exactError.message ?? exactResult.reportPath ?? exactErrorDetails.reportPath), 96),
exactResult?.status,
webObserveShort(webObserveText(exactResult?.reportSha256 ?? exactErrorDetails?.reportSha256), 32),
webObserveShort(webObserveText(exactError.message ?? exactResult?.reportPath ?? exactErrorDetails?.reportPath), 96),
]]),
"",
] : []),
@@ -180,7 +180,15 @@ function renderWebObserveStatusTable(value: Record<string, unknown>): string {
...renderWorkbenchKafkaDebugReplayEvidence(
exactReplayEvidence,
exactResult?.reportSha256 ?? exactErrorDetails?.reportSha256,
record(exactResult?.screenshot)?.sha256,
record(exactResult?.screenshot ?? exactErrorDetails?.screenshot)?.sha256,
),
"",
] : []),
...(exactCommand.type === "validateWorkbenchTraceReadability" && exactReplayEvidence !== null ? [
...renderWorkbenchTraceReadabilityEvidence(
exactReplayEvidence,
exactResult?.reportSha256 ?? exactErrorDetails?.reportSha256,
record(exactResult?.screenshot ?? exactErrorDetails?.screenshot)?.sha256,
),
"",
] : []),
@@ -239,6 +247,8 @@ function renderWorkbenchKafkaDebugReplayEvidence(
const trace = record(evidence.traceTimeline);
const raw = record(evidence.rawHwlabEventWindow);
const rawCounts = record(raw?.counts);
const layerDom = record(evidence.layerDom);
const layerStable = record(evidence.layerDomStabilization);
return [
"Workbench Kafka debug replay:",
webObserveTable(["TRACE", "TOPIC", "GROUP", "PHASE", "CODE", "RECEIVED", "APPLIED", "TERMINAL", "REPORT_SHA", "SCREENSHOT_SHA"], [[
@@ -262,6 +272,18 @@ function renderWorkbenchKafkaDebugReplayEvidence(
client?.decoded,
client?.applied,
]]),
"分层 DOM 诊断:",
webObserveTable(["MATCH", "VISIBLE", "CONNECTED", "TEXT_BYTES", "TEXT_HASH", "STABLE", "TIMED_OUT", "SAMPLES", "ELAPSED_MS"], [[
layerDom?.matchCount,
layerDom?.visibleCount,
layerDom?.connectedCount,
layerDom?.textBytes,
webObserveShort(webObserveText(layerDom?.textHash), 32),
layerStable?.stable,
layerStable?.timedOut,
layerStable?.sampleCount,
layerStable?.elapsedMs,
]]),
"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,
@@ -280,16 +302,69 @@ function renderWorkbenchKafkaDebugReplayEvidence(
];
}
function renderWorkbenchTraceReadabilityEvidence(
evidence: Record<string, unknown>,
reportSha: unknown,
screenshotSha: unknown,
): string[] {
const scope = record(evidence.scope);
const disclosure = record(evidence.disclosure);
const running = record(evidence.running);
const runningSnapshot = record(running?.snapshot);
const terminal = record(evidence.terminal);
const terminalSnapshot = record(terminal?.snapshot);
const retention = record(evidence.runningRetention);
const failures = Array.isArray(evidence.failures)
? evidence.failures.map(record).filter((item): item is Record<string, unknown> => item !== null)
: [];
return [
"Workbench 产品 Trace 可读性:",
webObserveTable(["TRACE", "STARTED_RUNNING", "RUNNING_OBSERVED", "AUTO_READABLE", "OPEN_BEFORE", "FORCED", "OPEN_TERMINAL", "RETENTION_MODE", "RETENTION_OK", "RETAINED", "REPORT_SHA", "SCREENSHOT_SHA"], [[
webObserveShort(webObserveText(evidence.traceId), 28),
evidence.startedDuringRunning,
running?.observed,
disclosure?.autoReadable,
disclosure?.openBefore,
evidence.forcedExpansion,
disclosure?.openAfterTerminal,
retention?.mode,
retention?.passed,
evidence.retainedRunningRowCount,
webObserveShort(webObserveText(reportSha), 32),
webObserveShort(webObserveText(screenshotSha), 32),
]]),
"产品 Trace DOM",
webObserveTable(["CONVERSATION", "PRODUCT_CARDS", "DEBUG_IN_SCOPE", "RUN_STATUS", "RUN_ROWS", "RUN_SOURCE", "TERM_STATUS", "TERM_ROWS", "TERM_SOURCE", "TERM_PROJECTED", "TERM_STABLE"], [[
scope?.conversationCount,
scope?.productCardCount,
scope?.debugPanelCountInsideConversation,
runningSnapshot?.status,
runningSnapshot?.rowCount,
runningSnapshot?.sourceAuthorityRowCount,
terminalSnapshot?.status,
terminalSnapshot?.rowCount,
terminalSnapshot?.sourceAuthorityRowCount,
terminalSnapshot?.projectedAuthorityRowCount,
terminal?.stable,
]]),
"失败项:",
failures.length > 0
? failures.map((failure) => webObserveText(failure.code)).join(",")
: "-",
];
}
function renderWebObserveCommandTable(value: Record<string, unknown>): string {
const observerCommand = record(value.observerCommand);
const observer = record(value.observer);
const control = record(value.control);
const asyncTurn = record(value.asyncTurn);
const result = record(observer?.result);
const result = nullableRecord(observer?.result);
const resultScreenshot = record(result?.screenshot);
const error = record(observer?.error);
const details = record(error?.details);
const details = nullableRecord(error?.details);
const replayEvidence = result ?? details;
const replayScreenshot = record(result?.screenshot ?? details?.screenshot);
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);
@@ -313,7 +388,15 @@ function renderWebObserveCommandTable(value: Record<string, unknown>): string {
...renderWorkbenchKafkaDebugReplayEvidence(
replayEvidence,
result?.reportSha256 ?? details?.reportSha256,
resultScreenshot?.sha256,
replayScreenshot?.sha256 ?? resultScreenshot?.sha256,
),
] : []),
...(observerCommand?.type === "validateWorkbenchTraceReadability" && replayEvidence !== null ? [
"",
...renderWorkbenchTraceReadabilityEvidence(
replayEvidence,
result?.reportSha256 ?? details?.reportSha256,
replayScreenshot?.sha256 ?? resultScreenshot?.sha256,
),
] : []),
...(readiness !== null ? [
@@ -103,6 +103,7 @@ async function processCommand(command) {
}), "sendPrompt");
case "validateRealtimeFanout": return validateRealtimeFanout(command);
case "validateWorkbenchKafkaDebugReplay": return withObserverSync(await validateWorkbenchKafkaDebugReplay(command), "validateWorkbenchKafkaDebugReplay");
case "validateWorkbenchTraceReadability": return withObserverSync(await validateWorkbenchTraceReadability(command), "validateWorkbenchTraceReadability");
case "steer": return withObserverSync(await sendPrompt(String(command.text || ""), { expectedAction: "steer", responsePath: "/v1/agent/chat/steer", noActiveReason: "steer-no-active-turn", expectedActionWaitMs: command.expectedActionWaitMs }), "steer");
case "cancel": return withObserverSync(await cancelRunningTurn(), "cancel");
case "selectProvider": return withObserverSync(await selectProvider(String(command.provider || command.value || command.text || "")), "selectProvider");
@@ -126,6 +126,9 @@ test("generated observer runner contains the typed debug replay command and pass
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, /workbenchKafkaDebugReplayWaitForStablePanel/u);
assert.match(source, /layerDomStabilization/u);
assert.match(source, /textHash/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);
@@ -196,7 +196,11 @@ async function validateWorkbenchKafkaDebugReplay(command) {
requestPhase = "debug-replay";
await replayButton.click({ timeout: 10000 });
const terminalStatus = await workbenchKafkaDebugReplayWaitForTerminal(panel, config.completionTimeoutMs);
const after = await workbenchKafkaDebugReplayPanelSnapshot(panel, countsNode);
const after = await workbenchKafkaDebugReplayWaitForStablePanel(
panel,
countsNode,
config.requestObservationQuietMs
);
Object.assign(report, {
sessionId,
traceId: after.traceId,
@@ -207,6 +211,8 @@ async function validateWorkbenchKafkaDebugReplay(command) {
code: after.typedOutcome.code,
typedOutcome: after.typedOutcome,
layerCounts: after.layerCounts,
layerDom: after.layerDom,
layerDomStabilization: after.layerDomStabilization,
legacyCounts: after.counts,
valuesRedacted: true
});
@@ -231,6 +237,8 @@ async function validateWorkbenchKafkaDebugReplay(command) {
code: after.typedOutcome.code,
typedOutcome: after.typedOutcome,
layerCounts: after.layerCounts,
layerDom: after.layerDom,
layerDomStabilization: after.layerDomStabilization,
legacyCounts: after.counts,
traceTimeline,
rawHwlabEventWindow,
@@ -260,6 +268,9 @@ async function validateWorkbenchKafkaDebugReplay(command) {
if (after.typedOutcome.phase !== "terminal" || after.typedOutcome.code !== "terminal_complete") {
throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay typed terminal contract is not terminal_complete", replayEvidence);
}
if (after.layerDomStabilization?.stable !== true) {
throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay layer DOM did not stabilize after terminal status", replayEvidence);
}
workbenchKafkaDebugReplayValidateLayerCounts(after.layerCounts, replayEvidence);
workbenchKafkaDebugReplayValidateTraceTimeline(traceTimeline, replayEvidence);
workbenchKafkaDebugReplayValidateRawWindow(rawHwlabEventWindow, replayEvidence);
@@ -292,6 +303,8 @@ async function validateWorkbenchKafkaDebugReplay(command) {
receivedCount: report.receivedCount,
appliedCount: report.appliedCount,
layerCounts: report.layerCounts,
layerDom: report.layerDom,
layerDomStabilization: report.layerDomStabilization,
traceTimeline: report.traceTimeline,
rawHwlabEventWindow: report.rawHwlabEventWindow,
reportPath: report.reportPath,
@@ -312,6 +325,8 @@ async function validateWorkbenchKafkaDebugReplay(command) {
receivedCount: report.receivedCount,
appliedCount: report.appliedCount,
layerCounts: report.layerCounts,
layerDom: report.layerDom,
layerDomStabilization: report.layerDomStabilization,
traceTimeline: report.traceTimeline,
rawHwlabEventWindow: report.rawHwlabEventWindow,
productEventSource: report.productEventSource,
@@ -330,6 +345,11 @@ async function validateWorkbenchKafkaDebugReplay(command) {
requests,
valuesRedacted: true
});
if (!report.screenshot) {
report.screenshot = await captureScreenshot("workbench-kafka-debug-replay-failed-" + safeId(command.id))
.then((screenshot) => ({ path: screenshot.path, sha256: screenshot.sha256, valuesRedacted: true }))
.catch((screenshotError) => ({ available: false, error: errorSummary(screenshotError), valuesRedacted: true }));
}
const reportArtifact = await workbenchKafkaDebugReplayWriteReport(reportDir, report).catch(() => null);
const wrapped = error instanceof Error ? error : new Error(String(error));
wrapped.details = {
@@ -343,8 +363,11 @@ async function validateWorkbenchKafkaDebugReplay(command) {
phase: report.phase || wrapped.details?.phase || null,
code: report.code || wrapped.details?.code || null,
layerCounts: report.layerCounts || wrapped.details?.layerCounts || null,
layerDom: report.layerDom || wrapped.details?.layerDom || null,
layerDomStabilization: report.layerDomStabilization || wrapped.details?.layerDomStabilization || null,
traceTimeline: report.traceTimeline || wrapped.details?.traceTimeline || null,
rawHwlabEventWindow: report.rawHwlabEventWindow || wrapped.details?.rawHwlabEventWindow || null,
screenshot: report.screenshot || wrapped.details?.screenshot || null,
reportPath: reportArtifact?.path || null,
reportSha256: reportArtifact?.sha256 || null,
valuesRedacted: true
@@ -399,8 +422,8 @@ 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 layerDom = await workbenchKafkaDebugReplayLayerDomSnapshot(panel);
const layerText = layerDom.text || "";
const errorNode = panel.locator(".isolated-debug-error").first();
const errorText = await errorNode.count() > 0 ? String(await errorNode.textContent() || "").trim() : "";
const layerCounts = workbenchKafkaDebugReplayLayerCounts(layerText);
@@ -412,12 +435,100 @@ async function workbenchKafkaDebugReplayPanelSnapshot(panel, countsNode) {
deliveryText: contract.text,
counts: workbenchKafkaDebugReplayCounts(await countsNode.textContent()),
layerCounts,
layerDom,
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 workbenchKafkaDebugReplayLayerDomSnapshot(panel) {
const selector = '[data-testid="workbench-kafka-debug-layer-counts"]';
const nodes = await panel.locator(selector).evaluateAll((elements) => elements.map((element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
const text = element.textContent?.replace(/\s+/gu, " ").trim() || "";
return {
connected: element.isConnected,
visible: style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0,
text
};
}));
const selectedIndex = nodes.findIndex((node) => node.visible);
const selected = nodes[selectedIndex >= 0 ? selectedIndex : 0] || null;
const text = selected?.text || "";
return {
selector,
matchCount: nodes.length,
visibleCount: nodes.filter((node) => node.visible).length,
connectedCount: nodes.filter((node) => node.connected).length,
selectedIndex: selectedIndex >= 0 ? selectedIndex : nodes.length > 0 ? 0 : null,
text,
textPresent: text.length > 0,
textBytes: Buffer.byteLength(text),
textHash: sha256Text(text),
textPreview: truncate(text, 240),
valuesRedacted: true
};
}
async function workbenchKafkaDebugReplayWaitForStablePanel(panel, countsNode, timeoutMs) {
const startedAtMs = Date.now();
const deadline = startedAtMs + timeoutMs;
const stableQuietMs = Math.min(500, Math.max(100, Math.floor(timeoutMs / 4)));
let latest = await workbenchKafkaDebugReplayPanelSnapshot(panel, countsNode);
let previousSignature = null;
let stableSinceMs = null;
let sampleCount = 0;
while (Date.now() <= deadline) {
latest = await workbenchKafkaDebugReplayPanelSnapshot(panel, countsNode);
sampleCount += 1;
const signature = JSON.stringify({
status: latest.status,
counts: latest.counts,
layerCounts: latest.layerCounts,
layerTextHash: latest.layerDom?.textHash,
matchCount: latest.layerDom?.matchCount,
visibleCount: latest.layerDom?.visibleCount
});
const domReady = latest.layerDom?.matchCount === 1
&& latest.layerDom?.visibleCount === 1
&& latest.layerDom?.textPresent === true
&& latest.layerCounts !== null;
if (domReady && signature === previousSignature) {
stableSinceMs ??= Date.now();
if (Date.now() - stableSinceMs >= stableQuietMs) {
return {
...latest,
layerDomStabilization: {
stable: true,
timedOut: false,
sampleCount,
elapsedMs: Date.now() - startedAtMs,
stableQuietMs,
valuesRedacted: true
}
};
}
} else {
stableSinceMs = null;
}
previousSignature = signature;
await sleep(100);
}
return {
...latest,
layerDomStabilization: {
stable: false,
timedOut: true,
sampleCount,
elapsedMs: Date.now() - startedAtMs,
stableQuietMs,
valuesRedacted: true
}
};
}
async function workbenchKafkaDebugReplayTraceTimelineSnapshot(panel) {
return panel.evaluate((root) => {
const rows = Array.from(root.querySelectorAll('[data-testid="trace-render-row"]'));
@@ -579,6 +690,8 @@ function workbenchKafkaDebugReplayEvidenceError(message, evidence) {
phase: evidence?.phase || null,
code: evidence?.code || null,
layerCounts: evidence?.layerCounts || null,
layerDom: evidence?.layerDom || null,
layerDomStabilization: evidence?.layerDomStabilization || null,
traceTimeline: evidence?.traceTimeline || null,
rawHwlabEventWindow: evidence?.rawHwlabEventWindow || null,
valuesRedacted: true
@@ -10,6 +10,7 @@ import { nodeWebObserveRunnerSamplingSource } from "./hwlab-node-web-observe-run
import { nodeWebObserveRunnerUtilitySource } from "./hwlab-node-web-observe-runner-utility-source";
import { nodeWebObserveRunnerRealtimeSource } from "./hwlab-node-web-observe-runner-realtime-source";
import { nodeWebObserveRunnerKafkaDebugReplaySource } from "./hwlab-node-web-observe-runner-kafka-debug-replay-source";
import { nodeWebObserveRunnerTraceReadabilitySource } from "./hwlab-node-web-observe-runner-trace-readability-source";
export function nodeWebObserveRunnerSource(): string {
return String.raw`#!/usr/bin/env node
@@ -54,6 +55,7 @@ const browserFreezePolicy = parseBrowserFreezePolicy(process.env.UNIDESK_WEB_OBS
const projectManagement = parseProjectManagementConfig(process.env.UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON);
const realtimeFanoutProfiles = parseRealtimeFanoutProfiles(process.env.UNIDESK_WEB_OBSERVE_REALTIME_FANOUT_PROFILES_JSON);
const workbenchKafkaDebugReplay = parseWorkbenchKafkaDebugReplayConfig(process.env.UNIDESK_WEB_OBSERVE_WORKBENCH_KAFKA_DEBUG_REPLAY_JSON);
const workbenchTraceReadability = parseWorkbenchTraceReadabilityConfig(process.env.UNIDESK_WEB_OBSERVE_WORKBENCH_TRACE_READABILITY_JSON);
const playwrightProxy = proxyConfigFromEnv(baseUrl);
const chromiumLaunchOptions = chromiumLaunchOptionsForProxy(playwrightProxy);
const pageId = "control-" + randomBytes(4).toString("hex");
@@ -201,6 +203,8 @@ ${nodeWebObserveRunnerRealtimeSource()}
${nodeWebObserveRunnerKafkaDebugReplaySource()}
${nodeWebObserveRunnerTraceReadabilitySource()}
${nodeWebObserveRunnerSamplingSource()}
${nodeWebObserveRunnerUtilitySource()}
@@ -0,0 +1,172 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import { nodeWebObserveRunnerSource } from "./hwlab-node-web-observe-runner-source";
import { nodeWebObserveRunnerTraceReadabilitySource } from "./hwlab-node-web-observe-runner-trace-readability-source";
test("Workbench 产品 Trace 可读性源码解析独立 YAML 时序并识别终态", () => {
const source = nodeWebObserveRunnerTraceReadabilitySource();
const build = new Function(`${source}\nreturn { parseWorkbenchTraceReadabilityConfig, workbenchTraceReadabilityTerminalStatus, workbenchTraceReadabilityFailures, workbenchTraceReadabilityRetention };`) as () => {
parseWorkbenchTraceReadabilityConfig: (raw: string) => Record<string, unknown>;
workbenchTraceReadabilityTerminalStatus: (value: string) => boolean;
workbenchTraceReadabilityFailures: (value: Record<string, unknown>) => Array<Record<string, unknown>>;
workbenchTraceReadabilityRetention: (running: Record<string, unknown>, terminal: Record<string, unknown>) => Record<string, unknown>;
};
const helpers = build();
assert.deepEqual(helpers.parseWorkbenchTraceReadabilityConfig(JSON.stringify({
enabled: true,
runningCardTimeoutMs: 15_000,
disclosureActionTimeoutMs: 15_000,
disclosureOpenTimeoutMs: 10_000,
runningReadableTimeoutMs: 480_000,
terminalTimeoutMs: 480_000,
domStableQuietMs: 2_000,
domStableTimeoutMs: 6_000,
pollIntervalMs: 100,
})), {
enabled: true,
runningCardTimeoutMs: 15_000,
disclosureActionTimeoutMs: 15_000,
disclosureOpenTimeoutMs: 10_000,
runningReadableTimeoutMs: 480_000,
terminalTimeoutMs: 480_000,
domStableQuietMs: 2_000,
domStableTimeoutMs: 6_000,
pollIntervalMs: 100,
valuesRedacted: true,
});
assert.equal(helpers.workbenchTraceReadabilityTerminalStatus("completed"), true);
assert.equal(helpers.workbenchTraceReadabilityTerminalStatus("running"), false);
assert.deepEqual(helpers.workbenchTraceReadabilityFailures({
scope: { conversationCount: 1, debugPanelCountInsideConversation: 0 },
startedDuringRunning: true,
disclosure: { openBefore: true, autoReadable: true },
running: { observed: true, snapshot: { rowCount: 2, sourceAuthorityRowCount: 2, projectedAuthorityRowCount: 0 } },
terminalArrival: { observed: true },
terminal: {
stable: true,
snapshot: {
disclosure: { open: true },
rowCount: 3,
sourceAuthorityRowCount: 3,
readableSourceRowCount: 3,
projectedAuthorityRowCount: 0,
},
},
runningRetention: { passed: true },
retainedRunningRowCount: 2,
}), []);
assert.deepEqual(
helpers.workbenchTraceReadabilityFailures({
scope: { conversationCount: 1, debugPanelCountInsideConversation: 0 },
startedDuringRunning: true,
disclosure: { openBefore: false, autoReadable: false },
running: { observed: true, snapshot: { rowCount: 2, sourceAuthorityRowCount: 2, projectedAuthorityRowCount: 0 } },
terminalArrival: { observed: true },
terminal: { stable: true, snapshot: { disclosure: { open: true }, rowCount: 2, sourceAuthorityRowCount: 2, readableSourceRowCount: 2, projectedAuthorityRowCount: 0 } },
runningRetention: { passed: true },
retainedRunningRowCount: 1,
}).map((failure) => failure.code),
["trace_not_auto_expanded"],
);
assert.deepEqual(helpers.workbenchTraceReadabilityRetention({
rowIdentities: ["source:1:tool"],
disclosure: { eventCount: 121 },
sourceSeqMin: 1,
sourceSeqMax: 120,
}, {
rowIdentities: ["source:121:terminal"],
disclosure: { eventCount: 122, rowWindowed: true },
sourceSeqMin: 2,
sourceSeqMax: 121,
}), {
mode: "window-progress",
passed: true,
retainedRowCount: 0,
runningEventCount: 121,
terminalEventCount: 122,
runningSourceSeqMin: 1,
runningSourceSeqMax: 120,
terminalSourceSeqMin: 2,
terminalSourceSeqMax: 121,
valuesRedacted: true,
});
});
test("运行卡片命中后使用 traceId 稳定定位,不随状态变更漂移", async () => {
const source = nodeWebObserveRunnerTraceReadabilitySource();
const runningTimeline = {
status: "running",
getAttribute(name: string) {
return name === "data-status" ? this.status : null;
},
};
const cardElement = (traceId: string, timeline: typeof runningTimeline) => ({
getAttribute(name: string) {
return name === "data-trace-id" ? traceId : null;
},
querySelector() {
return timeline;
},
});
const selectedTraceId = "trc_current";
const stableCard = {
async count() { return 1; },
locator() { return { count: async () => 1 }; },
async getAttribute(name: string) { return name === "data-trace-id" ? selectedTraceId : null; },
};
const cards = {
async count() { return 2; },
async evaluateAll(callback: (elements: unknown[]) => unknown) {
return callback([
cardElement("trc_old", { ...runningTimeline, status: "completed" }),
cardElement(selectedTraceId, runningTimeline),
]);
},
};
const conversation = {
locator(selector: string) {
if (selector === ':scope > .message-card[data-role="agent"][data-trace-id]') return cards;
if (selector === '[data-testid="workbench-kafka-debug-panel"]') return { count: async () => 0 };
if (selector.includes(`[data-trace-id="${selectedTraceId}"]`)) return { first: () => stableCard };
throw new Error(`unexpected selector: ${selector}`);
},
};
const conversationRoot = {
first: () => conversation,
count: async () => 1,
};
const page = {
locator(selector: string) {
if (selector === "#conversation-list") return conversationRoot;
throw new Error(`unexpected page selector: ${selector}`);
},
};
const build = new Function("page", "sleep", `${source}\nreturn { workbenchTraceReadabilityWaitForCurrentProductCard };`) as (
pageValue: typeof page,
sleepValue: () => Promise<void>,
) => {
workbenchTraceReadabilityWaitForCurrentProductCard: (timeoutMs: number, pollIntervalMs: number) => Promise<Record<string, any>>;
};
const helpers = build(page, async () => {});
const target = await helpers.workbenchTraceReadabilityWaitForCurrentProductCard(1_000, 1);
runningTimeline.status = "completed";
assert.equal(target.traceId, selectedTraceId);
assert.equal(target.card, stableCard);
assert.equal(await target.card.getAttribute("data-trace-id"), selectedTraceId);
});
test("生成的 observer runner 包含产品 Trace 命令并通过 Node 语法检查", async () => {
const source = nodeWebObserveRunnerSource();
assert.match(source, /validateWorkbenchTraceReadability/u);
assert.match(source, /#conversation-list/u);
assert.match(source, /debugPanelCountInsideConversation/u);
assert.match(source, /trace_not_auto_expanded/u);
const child = Bun.spawn(["node", "--input-type=module", "--check", "-"], { stdin: "pipe", stdout: "pipe", stderr: "pipe" });
child.stdin.write(source);
child.stdin.end();
const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited]);
assert.equal(exitCode, 0, stderr);
});
@@ -0,0 +1,577 @@
// SPEC: PJ2026-010401080313 Workbench 实时权威。
// 职责:在持久 Web observer 中验证产品 Workbench Trace 的可读性。
export function nodeWebObserveRunnerTraceReadabilitySource(): string {
return String.raw`
function parseWorkbenchTraceReadabilityConfig(raw) {
if (!raw) return null;
let parsed;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error("UNIDESK_WEB_OBSERVE_WORKBENCH_TRACE_READABILITY_JSON 不是合法 JSON" + (error instanceof Error ? error.message : String(error)));
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("UNIDESK_WEB_OBSERVE_WORKBENCH_TRACE_READABILITY_JSON 必须是对象");
}
if (typeof parsed.enabled !== "boolean") throw new Error("Workbench Trace 可读性 enabled 必须由 owning YAML 显式声明为布尔值");
const positiveInteger = (key) => {
const value = Number(parsed[key]);
if (!Number.isInteger(value) || value <= 0) throw new Error("Workbench Trace 可读性 " + key + " 必须是 owning YAML 中的正整数");
return value;
};
return {
enabled: parsed.enabled,
runningCardTimeoutMs: positiveInteger("runningCardTimeoutMs"),
disclosureActionTimeoutMs: positiveInteger("disclosureActionTimeoutMs"),
disclosureOpenTimeoutMs: positiveInteger("disclosureOpenTimeoutMs"),
runningReadableTimeoutMs: positiveInteger("runningReadableTimeoutMs"),
terminalTimeoutMs: positiveInteger("terminalTimeoutMs"),
domStableQuietMs: positiveInteger("domStableQuietMs"),
domStableTimeoutMs: positiveInteger("domStableTimeoutMs"),
pollIntervalMs: positiveInteger("pollIntervalMs"),
valuesRedacted: true
};
}
function workbenchTraceReadabilityTerminalStatus(value) {
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"]
.includes(String(value || "").trim().toLowerCase().replace(/_/gu, "-"));
}
async function validateWorkbenchTraceReadability(command) {
const config = workbenchTraceReadability;
if (!config || config.enabled !== true) throw new Error("owning YAML 未启用 validateWorkbenchTraceReadability");
const reportDir = path.join(stateDir, "artifacts", "workbench-trace-readability", safeId(command.id));
const report = {
ok: false,
contract: "web-probe-workbench-trace-readability-v1",
commandId: command.id,
origin,
expected: {
runningCardTimeoutMs: config.runningCardTimeoutMs,
disclosureActionTimeoutMs: config.disclosureActionTimeoutMs,
disclosureOpenTimeoutMs: config.disclosureOpenTimeoutMs,
runningReadableTimeoutMs: config.runningReadableTimeoutMs,
terminalTimeoutMs: config.terminalTimeoutMs,
domStableQuietMs: config.domStableQuietMs,
domStableTimeoutMs: config.domStableTimeoutMs,
pollIntervalMs: config.pollIntervalMs,
scope: "#conversation-list product WorkbenchMessageCard",
sequenceAuthority: "source",
valuesRedacted: true
},
startedAt: new Date().toISOString(),
valuesRedacted: true
};
try {
const controlRecovery = await ensureControlPageResponsiveForCommand("validateWorkbenchTraceReadability");
Object.assign(report, { controlRecovery });
const currentPath = safeUrlPath(currentPageUrl()) || "";
if (!isWorkbenchPathname(currentPath)) throw new Error("validateWorkbenchTraceReadability 必须在原 Workbench 页面执行");
const session = await workbenchSessionSnapshot();
const sessionId = session?.activeSessionId || session?.routeSessionId || routeSessionIdFromUrl(currentPageUrl());
if (!sessionId) throw new Error("validateWorkbenchTraceReadability 需要当前已有 Workbench session");
Object.assign(report, { sessionId });
const target = await workbenchTraceReadabilityWaitForCurrentProductCard(
config.runningCardTimeoutMs,
config.pollIntervalMs
);
Object.assign(report, { traceId: target.traceId, scope: target.scope });
const before = await workbenchTraceReadabilitySnapshot(target.card, target.traceId);
const startedDuringRunning = !workbenchTraceReadabilityTerminalStatus(before.status);
const openBefore = before.disclosure.open === true;
let forcedExpansion = false;
Object.assign(report, {
before,
startedDuringRunning,
forcedExpansion,
disclosure: {
openBefore,
openAfterExpansion: null,
openAfterTerminal: null,
autoReadable: false,
valuesRedacted: true
}
});
if (!openBefore) {
const summary = target.card.locator("details.trace-disclosure > summary.trace-disclosure-summary").first();
await summary.waitFor({ state: "visible", timeout: config.disclosureActionTimeoutMs });
await summary.click({ timeout: config.disclosureActionTimeoutMs });
forcedExpansion = true;
report.forcedExpansion = true;
}
const afterExpansion = await workbenchTraceReadabilityWaitForDisclosureOpen(
target.card,
target.traceId,
config.disclosureOpenTimeoutMs,
config.pollIntervalMs
);
report.disclosure.openAfterExpansion = afterExpansion.disclosure?.open ?? null;
const running = startedDuringRunning
? await workbenchTraceReadabilityWaitForReadableRunning(
target.card,
target.traceId,
config.runningReadableTimeoutMs,
config.pollIntervalMs
)
: { observed: false, reason: "command-started-after-terminal", snapshot: before, valuesRedacted: true };
Object.assign(report, { running });
report.disclosure.autoReadable = openBefore && (startedDuringRunning
? running.observed === true
: before.rowCount > 0 && before.sourceAuthorityRowCount > 0 && before.readableSourceRowCount > 0);
const terminalArrival = await workbenchTraceReadabilityWaitForTerminal(
target.card,
target.traceId,
config.terminalTimeoutMs,
config.pollIntervalMs
);
Object.assign(report, { terminalArrival });
const terminal = await workbenchTraceReadabilityWaitForStableTerminal(
target.card,
target.traceId,
config.domStableQuietMs,
config.domStableTimeoutMs,
config.pollIntervalMs
);
Object.assign(report, { terminal });
report.disclosure.openAfterTerminal = terminal.snapshot?.disclosure?.open ?? null;
const runningRetention = workbenchTraceReadabilityRetention(running.snapshot, terminal.snapshot);
const retainedRunningRowCount = runningRetention.retainedRowCount;
Object.assign(report, { runningRetention, retainedRunningRowCount });
const screenshot = await captureScreenshot("workbench-trace-readability-" + safeId(command.id));
const evidence = {
sessionId,
traceId: target.traceId,
scope: target.scope,
startedDuringRunning,
forcedExpansion,
disclosure: {
openBefore,
openAfterExpansion: afterExpansion.disclosure?.open ?? null,
openAfterTerminal: terminal.snapshot?.disclosure?.open ?? null,
autoReadable: openBefore && (startedDuringRunning
? running.observed === true
: before.rowCount > 0 && before.sourceAuthorityRowCount > 0 && before.readableSourceRowCount > 0),
valuesRedacted: true
},
running,
terminalArrival,
terminal,
runningRetention,
retainedRunningRowCount,
screenshot: { path: screenshot.path, sha256: screenshot.sha256, valuesRedacted: true },
controlRecovery,
valuesRedacted: true
};
Object.assign(report, evidence);
const failures = workbenchTraceReadabilityFailures(evidence);
report.failures = failures;
report.ok = failures.length === 0;
report.status = failures.length === 0 ? "passed" : "failed";
report.completedAt = new Date().toISOString();
const reportArtifact = await workbenchTraceReadabilityWriteReport(reportDir, report);
report.reportPath = reportArtifact.path;
report.reportSha256 = reportArtifact.sha256;
if (failures.length > 0) {
throw workbenchTraceReadabilityEvidenceError(
"Workbench 产品 Trace 可读性验证失败:" + failures.map((failure) => failure.code).join(","),
report
);
}
await appendJsonl(files.control, eventRecord("workbench-trace-readability-validated", {
sessionId,
traceId: report.traceId,
disclosure: report.disclosure,
running: report.running,
terminal: report.terminal,
runningRetention: report.runningRetention,
retainedRunningRowCount,
reportPath: report.reportPath,
reportSha256: report.reportSha256,
screenshot: report.screenshot,
valuesRedacted: true
}));
return {
ok: true,
status: "passed",
sessionId,
traceId: report.traceId,
scope: report.scope,
disclosure: report.disclosure,
running: report.running,
terminalArrival: report.terminalArrival,
terminal: report.terminal,
runningRetention: report.runningRetention,
retainedRunningRowCount,
reportPath: report.reportPath,
reportSha256: report.reportSha256,
screenshot: report.screenshot,
valuesRedacted: true
};
} catch (error) {
const wrapped = error instanceof Error ? error : new Error(String(error));
const partial = wrapped.details && typeof wrapped.details === "object" && !Array.isArray(wrapped.details)
? wrapped.details
: {};
for (const key of ["sessionId", "traceId", "scope", "before", "disclosure", "running", "terminalArrival", "terminal", "runningRetention", "retainedRunningRowCount", "failures"]) {
if ((report[key] === undefined || report[key] === null) && partial[key] !== undefined && partial[key] !== null) report[key] = partial[key];
}
if (!report.completedAt) {
Object.assign(report, {
ok: false,
status: "failed",
completedAt: new Date().toISOString(),
failure: errorSummary(error),
valuesRedacted: true
});
}
if (!Array.isArray(report.failures)) {
report.failures = [{ code: "probe_exception", message: wrapped.message, valuesRedacted: true }];
}
if (!report.screenshot) {
report.screenshot = await captureScreenshot("workbench-trace-readability-failed-" + safeId(command.id))
.then((screenshot) => ({ path: screenshot.path, sha256: screenshot.sha256, valuesRedacted: true }))
.catch((screenshotError) => ({ available: false, error: errorSummary(screenshotError), valuesRedacted: true }));
}
const existingReportPath = wrapped.details?.reportPath || report.reportPath || null;
const existingReportSha256 = wrapped.details?.reportSha256 || report.reportSha256 || null;
const reportArtifact = existingReportPath && existingReportSha256
? null
: await workbenchTraceReadabilityWriteReport(reportDir, report).catch(() => null);
wrapped.details = {
...(wrapped.details || {}),
sessionId: report.sessionId || wrapped.details?.sessionId || null,
traceId: report.traceId || wrapped.details?.traceId || null,
scope: report.scope || wrapped.details?.scope || null,
before: report.before || wrapped.details?.before || null,
disclosure: report.disclosure || wrapped.details?.disclosure || null,
running: report.running || wrapped.details?.running || null,
terminalArrival: report.terminalArrival || wrapped.details?.terminalArrival || null,
terminal: report.terminal || wrapped.details?.terminal || null,
runningRetention: report.runningRetention || wrapped.details?.runningRetention || null,
retainedRunningRowCount: report.retainedRunningRowCount ?? wrapped.details?.retainedRunningRowCount ?? null,
failures: report.failures || wrapped.details?.failures || null,
screenshot: report.screenshot || wrapped.details?.screenshot || null,
reportPath: existingReportPath || reportArtifact?.path || null,
reportSha256: existingReportSha256 || reportArtifact?.sha256 || null,
valuesRedacted: true
};
throw wrapped;
}
}
async function workbenchTraceReadabilityWaitForCurrentProductCard(timeoutMs, pollIntervalMs) {
const deadline = Date.now() + timeoutMs;
let lastScope = null;
while (Date.now() <= deadline) {
const conversation = page.locator("#conversation-list").first();
const conversationCount = await page.locator("#conversation-list").count();
const cards = conversation.locator(':scope > .message-card[data-role="agent"][data-trace-id]');
const productCardCount = conversationCount === 1 ? await cards.count() : 0;
const debugPanelCountInsideConversation = conversationCount === 1
? await conversation.locator('[data-testid="workbench-kafka-debug-panel"]').count()
: 0;
lastScope = {
selector: '#conversation-list > .message-card[data-role="agent"][data-trace-id]',
conversationCount,
productCardCount,
debugPanelCountInsideConversation,
isolatedDebugExcluded: debugPanelCountInsideConversation === 0,
valuesRedacted: true
};
if (conversationCount === 1 && productCardCount > 0) {
const runningTraceIds = await cards.evaluateAll((elements) => elements
.filter((element) => {
const timeline = element.querySelector(":scope > .trace-timeline");
const status = String(element.getAttribute("data-status") || timeline?.getAttribute("data-status") || "").trim().toLowerCase();
return status === "running" || status === "pending";
})
.map((element) => String(element.getAttribute("data-trace-id") || "").trim())
.filter((traceId) => /^trc_[A-Za-z0-9_-]+$/u.test(traceId)));
const traceId = runningTraceIds.at(-1) || null;
if (traceId) {
const card = conversation.locator(':scope > .message-card[data-role="agent"][data-trace-id="' + traceId + '"]').first();
const stableCardCount = await card.count();
const timelineCount = stableCardCount === 1 ? await card.locator(":scope > .trace-timeline").count() : 0;
if (stableCardCount === 1 && timelineCount === 1) return { card, traceId, scope: lastScope };
}
}
await sleep(pollIntervalMs);
}
const error = new Error("在 owning YAML 指定的 " + timeoutMs + "ms 内未找到当前新运行中的产品 Trace 卡片");
error.details = { scope: lastScope, valuesRedacted: true };
throw error;
}
async function workbenchTraceReadabilityWaitForDisclosureOpen(card, traceId, timeoutMs, pollIntervalMs) {
const deadline = Date.now() + timeoutMs;
let latest = await workbenchTraceReadabilitySnapshot(card, traceId);
while (Date.now() <= deadline) {
latest = await workbenchTraceReadabilitySnapshot(card, traceId);
if (latest.disclosure.open === true) return latest;
await sleep(pollIntervalMs);
}
return latest;
}
async function workbenchTraceReadabilitySnapshot(card, expectedTraceId) {
const raw = await card.evaluate((root) => {
const timeline = root.querySelector(":scope > .trace-timeline");
const disclosure = timeline?.querySelector(":scope > details.trace-disclosure");
const rows = disclosure ? Array.from(disclosure.querySelectorAll('[data-testid="trace-render-row"]')) : [];
const normalized = (value) => String(value || "").replace(/\s+/gu, " ").trim();
return {
connected: root.isConnected,
status: root.getAttribute("data-status") || timeline?.getAttribute("data-status") || null,
traceId: root.getAttribute("data-trace-id") || timeline?.getAttribute("data-trace-id") || null,
disclosure: {
available: Boolean(disclosure),
open: disclosure instanceof HTMLDetailsElement ? disclosure.open : null,
eventCount: disclosure?.getAttribute("data-event-count") || null,
readableRowCount: disclosure?.getAttribute("data-readable-row-count") || null,
renderedRowCount: disclosure?.getAttribute("data-rendered-row-count") || null,
rowWindowed: disclosure?.getAttribute("data-row-windowed") || null
},
rows: rows.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",
text: normalized(row.textContent)
}))
};
});
if (raw.connected !== true || raw.traceId !== expectedTraceId) {
const error = new Error("产品 Trace 卡片身份在验证期间发生变化");
error.details = {
traceId: expectedTraceId,
observedTraceId: raw.traceId || null,
connected: raw.connected === true,
valuesRedacted: true
};
throw error;
}
const rows = raw.rows.map((row) => ({
seq: row.seq,
authority: row.authority,
kind: row.kind,
status: row.status,
terminal: row.terminal,
identity: [row.authority || "-", row.seq || "-", row.kind || "-"].join(":"),
textPresent: row.text.length > 0,
textBytes: Buffer.byteLength(row.text),
textHash: sha256Text(row.text),
valuesRedacted: true
}));
const sourceRows = rows.filter((row) => row.authority === "source");
const projectedRows = rows.filter((row) => row.authority === "projected");
const sourceSeqs = sourceRows
.map((row) => Number(row.seq))
.filter((value) => Number.isInteger(value));
const integerAttribute = (value) => {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
};
return {
connected: raw.connected,
status: raw.status,
traceId: raw.traceId,
disclosure: {
...raw.disclosure,
eventCount: integerAttribute(raw.disclosure.eventCount),
readableRowCount: integerAttribute(raw.disclosure.readableRowCount),
renderedRowCount: integerAttribute(raw.disclosure.renderedRowCount),
rowWindowed: raw.disclosure.rowWindowed === "true"
? true
: raw.disclosure.rowWindowed === "false"
? false
: null
},
rowCount: rows.length,
readableRowCount: rows.filter((row) => row.textPresent).length,
sourceAuthorityRowCount: sourceRows.length,
readableSourceRowCount: sourceRows.filter((row) => row.textPresent).length,
projectedAuthorityRowCount: projectedRows.length,
sourceSeqMin: sourceSeqs.length > 0 ? Math.min(...sourceSeqs) : null,
sourceSeqMax: sourceSeqs.length > 0 ? Math.max(...sourceSeqs) : null,
rowIdentities: rows.map((row) => row.identity),
rows: rows.slice(-12),
rowSetHash: sha256Text(rows.map((row) => row.identity + ":" + row.textHash).join("|")),
valuesRedacted: true
};
}
async function workbenchTraceReadabilityWaitForReadableRunning(card, traceId, timeoutMs, pollIntervalMs) {
const startedAtMs = Date.now();
const deadline = startedAtMs + timeoutMs;
let latest = await workbenchTraceReadabilitySnapshot(card, traceId);
while (Date.now() <= deadline) {
latest = await workbenchTraceReadabilitySnapshot(card, traceId);
if (!workbenchTraceReadabilityTerminalStatus(latest.status)
&& latest.disclosure.open === true
&& latest.rowCount > 0
&& latest.sourceAuthorityRowCount > 0
&& latest.readableSourceRowCount > 0) {
return { observed: true, elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
}
if (workbenchTraceReadabilityTerminalStatus(latest.status)) {
return { observed: false, reason: "terminal-before-readable-running-sample", elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
}
await sleep(pollIntervalMs);
}
return { observed: false, reason: "readable-running-timeout", elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
}
async function workbenchTraceReadabilityWaitForTerminal(card, traceId, timeoutMs, pollIntervalMs) {
const startedAtMs = Date.now();
const deadline = startedAtMs + timeoutMs;
let latest = await workbenchTraceReadabilitySnapshot(card, traceId);
while (Date.now() <= deadline) {
latest = await workbenchTraceReadabilitySnapshot(card, traceId);
if (workbenchTraceReadabilityTerminalStatus(latest.status)) {
return { observed: true, status: latest.status, elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
}
await sleep(pollIntervalMs);
}
return { observed: false, status: latest.status, elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
}
async function workbenchTraceReadabilityWaitForStableTerminal(card, traceId, quietMs, timeoutMs, pollIntervalMs) {
const startedAtMs = Date.now();
const deadline = startedAtMs + timeoutMs;
let stableSinceMs = null;
let previousSignature = null;
let latest = await workbenchTraceReadabilitySnapshot(card, traceId);
let sampleCount = 0;
while (Date.now() <= deadline) {
latest = await workbenchTraceReadabilitySnapshot(card, traceId);
sampleCount += 1;
const signature = [latest.status, latest.disclosure.open, latest.rowCount, latest.sourceAuthorityRowCount, latest.rowSetHash].join("|");
const ready = workbenchTraceReadabilityTerminalStatus(latest.status)
&& latest.disclosure.open === true
&& latest.rowCount > 0
&& latest.sourceAuthorityRowCount > 0
&& latest.readableSourceRowCount > 0;
if (ready && signature === previousSignature) {
stableSinceMs ??= Date.now();
if (Date.now() - stableSinceMs >= quietMs) {
return { observed: true, stable: true, sampleCount, elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
}
} else {
stableSinceMs = null;
}
previousSignature = signature;
await sleep(pollIntervalMs);
}
return { observed: workbenchTraceReadabilityTerminalStatus(latest.status), stable: false, sampleCount, elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
}
function workbenchTraceReadabilityRetention(running, terminal) {
const runningRowIds = new Set(running?.rowIdentities || []);
const terminalRowIds = new Set(terminal?.rowIdentities || []);
const retainedRowCount = [...runningRowIds].filter((identity) => terminalRowIds.has(identity)).length;
const windowed = terminal?.disclosure?.rowWindowed === true;
if (!windowed) {
return {
mode: "identity-overlap",
passed: retainedRowCount > 0,
retainedRowCount,
valuesRedacted: true
};
}
const runningEventCount = running?.disclosure?.eventCount;
const terminalEventCount = terminal?.disclosure?.eventCount;
const countsAdvance = Number.isInteger(runningEventCount)
&& Number.isInteger(terminalEventCount)
&& terminalEventCount >= runningEventCount;
const sequenceWindowAdvances = Number.isInteger(running?.sourceSeqMin)
&& Number.isInteger(running?.sourceSeqMax)
&& Number.isInteger(terminal?.sourceSeqMin)
&& Number.isInteger(terminal?.sourceSeqMax)
&& terminal.sourceSeqMin >= running.sourceSeqMin
&& terminal.sourceSeqMax >= running.sourceSeqMax;
return {
mode: "window-progress",
passed: countsAdvance && sequenceWindowAdvances,
retainedRowCount,
runningEventCount: runningEventCount ?? null,
terminalEventCount: terminalEventCount ?? null,
runningSourceSeqMin: running?.sourceSeqMin ?? null,
runningSourceSeqMax: running?.sourceSeqMax ?? null,
terminalSourceSeqMin: terminal?.sourceSeqMin ?? null,
terminalSourceSeqMax: terminal?.sourceSeqMax ?? null,
valuesRedacted: true
};
}
function workbenchTraceReadabilityFailures(evidence) {
const failures = [];
const add = (code, message) => failures.push({ code, message, valuesRedacted: true });
if (evidence.scope?.conversationCount !== 1 || evidence.scope?.debugPanelCountInsideConversation !== 0) {
add("product_trace_scope_invalid", "产品 Trace 作用域必须只有一个 #conversation-list,且不得包含隔离调试面板。");
}
if (evidence.startedDuringRunning !== true || evidence.running?.observed !== true) {
add("running_phase_not_observed", "命令未在当前 turn 运行期间观测到可读的产品 Trace。");
}
const running = evidence.running?.snapshot;
if (evidence.running?.observed === true
&& (running?.sourceAuthorityRowCount !== running?.rowCount || running?.projectedAuthorityRowCount !== 0)) {
add("running_source_authority_missing", "运行中的产品 Trace 行必须全部使用 source sequence authority。");
}
if (evidence.disclosure?.openBefore !== true || evidence.disclosure?.autoReadable !== true) {
add("trace_not_auto_expanded", "产品 Trace 在命令主动展开前没有默认保持可读。");
}
if (evidence.terminalArrival?.observed !== true) add("terminal_not_observed", "当前产品 Trace 未在 YAML 超时内进入终态。");
const terminal = evidence.terminal?.snapshot;
if (evidence.terminal?.stable !== true || terminal?.disclosure?.open !== true || terminal?.rowCount <= 0) {
add("terminal_trace_not_retained", "产品 Trace 行在终态后没有保留在展开的详情中。");
}
if (terminal?.sourceAuthorityRowCount <= 0
|| terminal?.readableSourceRowCount <= 0
|| terminal?.sourceAuthorityRowCount !== terminal?.rowCount
|| terminal?.projectedAuthorityRowCount !== 0) {
add("source_authority_missing", "终态产品 Trace 必须展示可读的 source-authority 行,且不得出现 projected-authority 行。");
}
if (evidence.running?.observed === true && evidence.runningRetention?.passed !== true) {
add("running_rows_not_retained", "运行期 Trace 未通过身份重叠或窗口前进合同证明终态保留。");
}
return failures;
}
function workbenchTraceReadabilityEvidenceError(message, evidence) {
const error = new Error(message);
error.details = {
sessionId: evidence?.sessionId || null,
traceId: evidence?.traceId || null,
scope: evidence?.scope || null,
disclosure: evidence?.disclosure || null,
running: evidence?.running || null,
terminalArrival: evidence?.terminalArrival || null,
terminal: evidence?.terminal || null,
runningRetention: evidence?.runningRetention || null,
retainedRunningRowCount: evidence?.retainedRunningRowCount ?? null,
failures: evidence?.failures || null,
screenshot: evidence?.screenshot || null,
reportPath: evidence?.reportPath || null,
reportSha256: evidence?.reportSha256 || null,
valuesRedacted: true
};
return error;
}
async function workbenchTraceReadabilityWriteReport(reportDir, report) {
await mkdir(reportDir, { recursive: true, mode: 0o700 });
const file = path.join(reportDir, "report.json");
await writeFile(file, JSON.stringify(sanitize(report), null, 2) + "\n", { mode: 0o600 });
const meta = await fileMeta(file);
const artifact = { kind: "workbench-trace-readability-report", path: file, byteCount: meta.byteCount, sha256: meta.sha256, commandId: activeCommandId, valuesRedacted: true };
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), ...artifact });
return artifact;
}
`;
}
+1
View File
@@ -140,6 +140,7 @@ export type NodeWebProbeObserveCommandType =
| "sendPrompt"
| "validateRealtimeFanout"
| "validateWorkbenchKafkaDebugReplay"
| "validateWorkbenchTraceReadability"
| "steer"
| "cancel"
| "selectProvider"
@@ -98,6 +98,19 @@ test("observe status renderer exposes bounded Workbench Kafka debug replay evide
server: { scanned: 35, matched: 35, delivered: 35 },
client: { received: 35, decoded: 35, applied: 35 },
},
layerDom: {
matchCount: 1,
visibleCount: 1,
connectedCount: 1,
textBytes: 128,
textHash: "sha256:layer-dom",
},
layerDomStabilization: {
stable: true,
timedOut: false,
sampleCount: 4,
elapsedMs: 400,
},
traceTimeline: {
contract: "trace-sequence-authority-v1",
sourceAuthorityRowCount: 35,
@@ -126,9 +139,141 @@ test("observe status renderer exposes bounded Workbench Kafka debug replay evide
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, / DOM /u);
assert.match(text, /1.*1.*1.*128.*sha256:layer-dom.*true.*false.*4.*400/u);
assert.match(text, /trace-sequence-authority-v1.*35.*35.*true.*true.*true.*35.*30.*0.*5.*8192.*0/u);
});
test("observe status renderer 展示限定作用域的产品 Trace 生命周期证据", () => {
const rendered = withWebObserveStatusRendered({
ok: true,
status: "running",
command: "web-probe observe status",
id: "webobs-fixture",
node: "NC01",
lane: "v03",
observer: {
processAlive: true,
manifest: { baseUrl: "http://internal.example.test", targetPath: "/workbench" },
heartbeat: { status: "running", sampleSeq: 12, commandSeq: 4, updatedAt: "2026-07-10T01:00:01.000Z" },
diagnostics: { effectiveLiveness: "alive", heartbeatStale: false },
commands: { pendingCount: 0, processingCount: 0, abandonedCount: 0, failedCount: 0 },
exactCommand: {
commandId: "cmd-trace-readable",
type: "validateWorkbenchTraceReadability",
phase: "done",
ok: true,
result: {
status: "passed",
traceId: "trc_fixture",
startedDuringRunning: true,
forcedExpansion: false,
scope: { conversationCount: 1, productCardCount: 2, debugPanelCountInsideConversation: 0 },
disclosure: { openBefore: true, autoReadable: true, openAfterTerminal: true },
running: { observed: true, snapshot: { status: "running", rowCount: 2, sourceAuthorityRowCount: 2 } },
terminal: { stable: true, snapshot: { status: "completed", rowCount: 3, sourceAuthorityRowCount: 3, projectedAuthorityRowCount: 0 } },
runningRetention: { mode: "identity-overlap", passed: true, retainedRowCount: 2 },
retainedRunningRowCount: 2,
reportSha256: "sha256:trace-report",
screenshot: { sha256: "sha256:trace-image" },
},
},
tails: { samples: [], control: [], network: [] },
},
next: {},
});
const text = String(rendered.renderedText);
assert.match(text, /Workbench Trace /u);
assert.match(text, /trc_fixture.*true.*true.*true.*true.*false.*true.*identity-overlap.*true.*2.*sha256:trace-report.*sha256:trace-image/u);
assert.match(text, /CONVERSATION.*PRODUCT_CARDS.*DEBUG_IN_SCOPE/u);
assert.match(text, /1.*2.*0.*running.*2.*2.*completed.*3.*3.*0.*true/u);
});
test("observe status renderer 在命令失败时保留产品 Trace typed evidence", () => {
const rendered = withWebObserveStatusRendered({
ok: true,
status: "running",
command: "web-probe observe status",
id: "webobs-fixture",
node: "NC01",
lane: "v03",
observer: {
processAlive: true,
manifest: {},
heartbeat: {},
diagnostics: {},
commands: {},
exactCommand: {
commandId: "cmd-trace-failed",
type: "validateWorkbenchTraceReadability",
phase: "failed",
ok: false,
error: {
message: "trace validation failed",
details: {
traceId: "trc_failed",
startedDuringRunning: true,
forcedExpansion: false,
scope: { conversationCount: 1, productCardCount: 1, debugPanelCountInsideConversation: 0 },
disclosure: { openBefore: false, autoReadable: false, openAfterTerminal: true },
running: { observed: true, snapshot: { status: "running", rowCount: 2, sourceAuthorityRowCount: 2 } },
terminal: { stable: true, snapshot: { status: "completed", rowCount: 3, sourceAuthorityRowCount: 3, projectedAuthorityRowCount: 0 } },
runningRetention: { mode: "identity-overlap", passed: true, retainedRowCount: 2 },
retainedRunningRowCount: 2,
failures: [{ code: "trace_not_auto_expanded" }],
reportSha256: "sha256:failed-trace-report",
},
},
},
tails: { samples: [], control: [], network: [] },
},
next: {},
});
const text = String(rendered.renderedText);
assert.match(text, /trc_failed.*identity-overlap.*true.*2.*sha256:failed-trace-report/u);
assert.match(text, /trace_not_auto_expanded/u);
});
test("observe command renderer 在隔离重放失败时保留分层 typed evidence", () => {
const rendered = withWebObserveCommandRendered({
ok: false,
status: "failed",
command: "web-probe observe command",
id: "webobs-fixture",
commandId: "cmd-debug-failed",
observerCommand: { type: "validateWorkbenchKafkaDebugReplay" },
observer: {
ok: false,
error: {
message: "debug trace missing",
details: {
traceId: "trc_debug_failed",
topic: "hwlab.event.debug.v1",
groupId: "hwlab-v03-workbench-isolated-debug-fixture",
phase: "filter",
code: "debug_trace_not_generated",
receivedCount: 0,
appliedCount: 0,
layerCounts: {
server: { scanned: 27, matched: 0, delivered: 0 },
client: { received: 0, decoded: 0, applied: 0 },
},
reportSha256: "sha256:failed-debug-report",
},
},
},
control: { executionStatus: "failed" },
asyncTurn: {},
});
const text = String(rendered.renderedText);
assert.match(text, /trc_debug_failed.*debug_trace_not_generated/u);
assert.match(text, /27.*0.*0.*0.*0.*0/u);
assert.match(text, /sha256:failed-debug-report/u);
});
test("web-probe script render warns to promote repeated scripts into commands", () => {
const commandPromotionHint = {
schemaVersion: "unidesk.web-probe.command-promotion.v1",
@@ -94,6 +94,22 @@ test("observe status preserves bounded Workbench Kafka debug replay evidence", a
server: { scanned: 35, matched: 35, delivered: 35 },
client: { received: 35, decoded: 35, applied: 35 },
},
layerDom: {
matchCount: 1,
visibleCount: 1,
connectedCount: 1,
textPresent: true,
textBytes: 128,
textHash: "sha256:layer",
textPreview: "server scanned=35 client received=35",
},
layerDomStabilization: {
stable: true,
timedOut: false,
sampleCount: 4,
elapsedMs: 400,
stableQuietMs: 250,
},
traceTimeline: {
contract: "trace-sequence-authority-v1",
sourceAuthorityRowCount: 35,
@@ -123,7 +139,107 @@ test("observe status preserves bounded Workbench Kafka debug replay evidence", a
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.layerDom.visibleCount, 1);
assert.equal(status.exactCommand.result.layerDom.textHash, "sha256:layer");
assert.equal(status.exactCommand.result.layerDomStabilization.stable, true);
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");
});
test("observe status preserves failed replay layer DOM and screenshot diagnostics", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-debug-replay-failed-status-"));
const commandId = "cmd-debug-replay-failed";
await mkdir(join(stateDir, "commands", "failed"), { recursive: true });
await writeFile(join(stateDir, "commands", "failed", `${commandId}.json`), JSON.stringify({
ok: false,
commandId,
type: "validateWorkbenchKafkaDebugReplay",
failedAt: "2026-07-10T12:00:00.000Z",
error: {
name: "Error",
message: "layer DOM did not stabilize",
details: {
traceId: "trc_fixture",
layerCounts: null,
layerDom: { matchCount: 1, visibleCount: 1, connectedCount: 1, textPresent: true, textBytes: 128, textHash: "sha256:failed-layer" },
layerDomStabilization: { stable: false, timedOut: true, sampleCount: 30, elapsedMs: 3000, stableQuietMs: 500 },
reportPath: "/tmp/failed-report.json",
reportSha256: "sha256:failed-report",
screenshot: { path: "/tmp/failed.png", sha256: "sha256:failed-image" },
},
},
}) + "\n");
const status = await runStatusScript(stateDir, commandId);
assert.equal(status.exactCommand.phase, "failed");
assert.equal(status.exactCommand.error.details.layerDom.visibleCount, 1);
assert.equal(status.exactCommand.error.details.layerDom.textHash, "sha256:failed-layer");
assert.equal(status.exactCommand.error.details.layerDomStabilization.timedOut, true);
assert.equal(status.exactCommand.error.details.reportSha256, "sha256:failed-report");
assert.equal(status.exactCommand.error.details.screenshot.sha256, "sha256:failed-image");
});
test("observe status preserves bounded scoped Workbench product Trace evidence", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-trace-readability-status-"));
const commandId = "cmd-trace-readable";
await mkdir(join(stateDir, "commands", "done"), { recursive: true });
await writeFile(join(stateDir, "commands", "done", `${commandId}.json`), JSON.stringify({
ok: true,
commandId,
type: "validateWorkbenchTraceReadability",
completedAt: "2026-07-10T12:00:00.000Z",
result: {
ok: true,
status: "passed",
sessionId: "ses_fixture",
traceId: "trc_fixture",
startedDuringRunning: true,
forcedExpansion: false,
scope: {
selector: "#conversation-list .message-card[data-role=agent][data-trace-id]",
conversationCount: 1,
productCardCount: 2,
debugPanelCountInsideConversation: 0,
isolatedDebugExcluded: true,
},
disclosure: {
openBefore: true,
openAfterExpansion: true,
openAfterTerminal: true,
autoReadable: true,
},
running: {
observed: true,
snapshot: { status: "running", traceId: "trc_fixture", rowCount: 2, sourceAuthorityRowCount: 2, projectedAuthorityRowCount: 0, rowSetHash: "sha256:running" },
},
terminal: {
observed: true,
stable: true,
snapshot: { status: "completed", traceId: "trc_fixture", disclosure: { open: true }, rowCount: 3, sourceAuthorityRowCount: 3, readableSourceRowCount: 3, projectedAuthorityRowCount: 0, rowSetHash: "sha256:terminal" },
},
runningRetention: {
mode: "identity-overlap",
passed: true,
retainedRowCount: 2,
},
retainedRunningRowCount: 2,
reportPath: "/tmp/trace-report.json",
reportSha256: "sha256:trace-report",
screenshot: { path: "/tmp/trace.png", sha256: "sha256:trace-image" },
},
}) + "\n");
const status = await runStatusScript(stateDir, commandId);
assert.equal(status.exactCommand.type, "validateWorkbenchTraceReadability");
assert.equal(status.exactCommand.result.scope.debugPanelCountInsideConversation, 0);
assert.equal(status.exactCommand.result.disclosure.openBefore, true);
assert.equal(status.exactCommand.result.running.snapshot.sourceAuthorityRowCount, 2);
assert.equal(status.exactCommand.result.terminal.snapshot.status, "completed");
assert.equal(status.exactCommand.result.terminal.snapshot.projectedAuthorityRowCount, 0);
assert.equal(status.exactCommand.result.runningRetention.mode, "identity-overlap");
assert.equal(status.exactCommand.result.runningRetention.passed, true);
assert.equal(status.exactCommand.result.retainedRunningRowCount, 2);
assert.equal(status.exactCommand.result.reportSha256, "sha256:trace-report");
assert.equal(status.exactCommand.result.screenshot.sha256, "sha256:trace-image");
});
+24 -2
View File
@@ -60,7 +60,29 @@ 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 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 layerDom=item.layerDom&&typeof item.layerDom==='object'?item.layerDom:null;
const layerStable=item.layerDomStabilization&&typeof item.layerDomStabilization==='object'?item.layerDomStabilization: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,
layerDom:layerDom?{matchCount:layerDom.matchCount??null,visibleCount:layerDom.visibleCount??null,connectedCount:layerDom.connectedCount??null,textPresent:layerDom.textPresent??null,textBytes:layerDom.textBytes??null,textHash:layerDom.textHash||null,textPreview:short(layerDom.textPreview),valuesRedacted:true}:null,
layerDomStabilization:layerStable?{stable:layerStable.stable??null,timedOut:layerStable.timedOut??null,sampleCount:layerStable.sampleCount??null,elapsedMs:layerStable.elapsedMs??null,stableQuietMs:layerStable.stableQuietMs??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 compactTraceSnapshot=(value)=>{const item=value&&typeof value==='object'?value:null;if(!item)return null;const disclosure=item.disclosure&&typeof item.disclosure==='object'?item.disclosure:null;return{status:item.status||null,traceId:item.traceId||null,disclosure:disclosure?{available:disclosure.available??null,open:disclosure.open??null,eventCount:disclosure.eventCount??null,readableRowCount:disclosure.readableRowCount??null,renderedRowCount:disclosure.renderedRowCount??null,rowWindowed:disclosure.rowWindowed??null}:null,rowCount:item.rowCount??null,readableRowCount:item.readableRowCount??null,sourceAuthorityRowCount:item.sourceAuthorityRowCount??null,readableSourceRowCount:item.readableSourceRowCount??null,projectedAuthorityRowCount:item.projectedAuthorityRowCount??null,rowSetHash:item.rowSetHash||null,valuesRedacted:true};};
const compactTracePhase=(value)=>{const item=value&&typeof value==='object'?value:null;return item?{observed:item.observed??null,stable:item.stable??null,reason:item.reason||null,status:item.status||null,elapsedMs:item.elapsedMs??null,sampleCount:item.sampleCount??null,snapshot:compactTraceSnapshot(item.snapshot),valuesRedacted:true}:null;};
const compactTraceReadabilityEvidence=(value)=>{const item=value&&typeof value==='object'?value:null;if(!item)return{};const scope=item.scope&&typeof item.scope==='object'?item.scope:null;const disclosure=item.disclosure&&typeof item.disclosure==='object'?item.disclosure:null;const retention=item.runningRetention&&typeof item.runningRetention==='object'?item.runningRetention:null;return{startedDuringRunning:item.startedDuringRunning??null,forcedExpansion:item.forcedExpansion??null,scope:scope?{selector:short(scope.selector),conversationCount:scope.conversationCount??null,productCardCount:scope.productCardCount??null,debugPanelCountInsideConversation:scope.debugPanelCountInsideConversation??null,isolatedDebugExcluded:scope.isolatedDebugExcluded??null,valuesRedacted:true}:null,disclosure:disclosure?{openBefore:disclosure.openBefore??null,openAfterExpansion:disclosure.openAfterExpansion??null,openAfterTerminal:disclosure.openAfterTerminal??null,autoReadable:disclosure.autoReadable??null,valuesRedacted:true}:null,running:compactTracePhase(item.running),terminalArrival:compactTracePhase(item.terminalArrival),terminal:compactTracePhase(item.terminal),runningRetention:retention?{mode:retention.mode||null,passed:retention.passed??null,retainedRowCount:retention.retainedRowCount??null,runningEventCount:retention.runningEventCount??null,terminalEventCount:retention.terminalEventCount??null,runningSourceSeqMin:retention.runningSourceSeqMin??null,runningSourceSeqMax:retention.runningSourceSeqMax??null,terminalSourceSeqMin:retention.terminalSourceSeqMin??null,terminalSourceSeqMax:retention.terminalSourceSeqMax??null,valuesRedacted:true}:null,retainedRunningRowCount:item.retainedRunningRowCount??null,failures:Array.isArray(item.failures)?item.failures.slice(0,8).map((failure)=>({code:failure&&failure.code||null,message:short(failure&&failure.message),valuesRedacted:true})):null};};
const compactScreenshot=(value)=>value&&typeof value==='object'?{path:value.path||null,sha256:value.sha256||null,available:value.available??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{}
@@ -75,7 +97,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,...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: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),...compactTraceReadabilityEvidence(result),warmRunnerReused:result.warmRunnerReused??null,forbiddenRequestCount:result.forbiddenRequestCount??null,replayedEventCount:result.replayedEventCount??null,reportPath:result.reportPath||null,reportSha256:result.reportSha256||null,screenshot:compactScreenshot(result.screenshot),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),...compactTraceReadabilityEvidence(error.details),sessionId:error.details.sessionId||null,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,screenshot:compactScreenshot(error.details.screenshot),valuesRedacted:true}:null}:null,valuesRedacted:true};
}
return {commandId:exactCommandId,phase:'not-found',ok:false,valuesRedacted:true};
};
@@ -0,0 +1,14 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import { hwlabNodeWebProbeHelp } from "../hwlab-node-help";
test("web-probe help promotes reusable Workbench debug and product Trace commands", () => {
const help = hwlabNodeWebProbeHelp();
const examples = Array.isArray(help.examples) ? help.examples.join("\n") : "";
const notes = Array.isArray(help.notes) ? help.notes.join("\n") : "";
assert.match(examples, /--type validateWorkbenchKafkaDebugReplay/u);
assert.match(examples, /--type validateWorkbenchTraceReadability/u);
assert.match(notes, /validateWorkbenchTraceReadability/u);
assert.match(notes, //u);
});
@@ -5,7 +5,7 @@ import { join } from "node:path";
import { test } from "bun:test";
import { hwlabRuntimeLaneSpecForNode } from "../hwlab-node-lanes";
import { buildWebObserveCommandVisibility, nodeWebProbeRealtimeFanoutProfiles, nodeWebProbeWorkbenchKafkaDebugReplay, resolveWebObserveActionJson } from "./web-probe-observe-actions";
import { buildWebObserveCommandVisibility, nodeWebProbeRealtimeFanoutProfiles, nodeWebProbeWorkbenchKafkaDebugReplay, nodeWebProbeWorkbenchTraceReadability, resolveWebObserveActionJson } from "./web-probe-observe-actions";
test("realtime fanout runner contract derives topics, groups, and independent capabilities from owning YAML", () => {
const profiles = nodeWebProbeRealtimeFanoutProfiles(hwlabRuntimeLaneSpecForNode("v03", "NC01"));
@@ -42,6 +42,21 @@ test("Workbench Kafka debug replay runner contract is derived from owning YAML",
});
});
test("Workbench 产品 Trace 可读性 runner 合同完全来自独立 owning YAML", () => {
assert.deepEqual(nodeWebProbeWorkbenchTraceReadability(hwlabRuntimeLaneSpecForNode("v03", "NC01")), {
enabled: true,
runningCardTimeoutMs: 15_000,
disclosureActionTimeoutMs: 15_000,
disclosureOpenTimeoutMs: 10_000,
runningReadableTimeoutMs: 480_000,
terminalTimeoutMs: 480_000,
domStableQuietMs: 2_000,
domStableTimeoutMs: 6_000,
pollIntervalMs: 100,
valuesRedacted: true,
});
});
test("web observe command visibility does not equate control completion with async turn completion", () => {
const visibility = buildWebObserveCommandVisibility({
commandType: "sendPrompt",
@@ -64,6 +64,15 @@ export function nodeWebProbeWorkbenchKafkaDebugReplay(spec: HwlabRuntimeLaneSpec
};
}
export function nodeWebProbeWorkbenchTraceReadability(spec: HwlabRuntimeLaneSpec): Record<string, unknown> | null {
const config = spec.webProbe?.workbenchTraceReadability;
if (config === undefined) return null;
return {
...config,
valuesRedacted: true,
};
}
export function resolveWebObserveActionJson(
result: { stdout: string; stderr?: string; exitCode?: number | null; timedOut?: boolean },
contract: WebObserveActionJsonContract,
@@ -492,6 +501,7 @@ export function runNodeWebProbeObserveStart(
`UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON=${shellQuote(JSON.stringify(projectManagement))}`,
`UNIDESK_WEB_OBSERVE_REALTIME_FANOUT_PROFILES_JSON=${shellQuote(JSON.stringify(nodeWebProbeRealtimeFanoutProfiles(spec)))}`,
`UNIDESK_WEB_OBSERVE_WORKBENCH_KAFKA_DEBUG_REPLAY_JSON=${shellQuote(JSON.stringify(nodeWebProbeWorkbenchKafkaDebugReplay(spec)))}`,
`UNIDESK_WEB_OBSERVE_WORKBENCH_TRACE_READABILITY_JSON=${shellQuote(JSON.stringify(nodeWebProbeWorkbenchTraceReadability(spec)))}`,
...(authLogin === null
? []
: [
@@ -746,6 +756,9 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption
if (type === "validateWorkbenchKafkaDebugReplay" && spec.webProbe?.workbenchKafkaDebugReplay?.enabled !== true) {
throw new Error("validateWorkbenchKafkaDebugReplay is not enabled by the owning YAML");
}
if (type === "validateWorkbenchTraceReadability" && nodeWebProbeWorkbenchTraceReadability(spec)?.enabled !== true) {
throw new Error("owning YAML 未启用 validateWorkbenchTraceReadability");
}
const commandId = `cmd-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
const payload = {
id: commandId,
@@ -77,6 +77,21 @@ test("validateWorkbenchKafkaDebugReplay is a YAML-enabled argument-free typed co
});
});
test("validateWorkbenchTraceReadability 是由独立 YAML 启用的无参数 typed command", () => {
const options = parseNodeWebProbeObserveOptions(
["command", "--type", "validateWorkbenchTraceReadability"],
"NC01",
"v03",
spec,
"webobs-fixture",
null,
);
assert.equal(options.commandType, "validateWorkbenchTraceReadability");
assert.equal(spec.webProbe?.workbenchTraceReadability?.terminalTimeoutMs, 480_000);
assert.equal(spec.webProbe?.workbenchTraceReadability?.runningCardTimeoutMs, 15_000);
});
test("observe start selects the YAML public origin semantically", () => {
const options = parseNodeWebProbeObserveOptions(
["start", "--origin", "public"],
+7 -1
View File
@@ -605,6 +605,11 @@ export function parseNodeWebProbeObserveOptions(
throw new Error("validateWorkbenchKafkaDebugReplay is not enabled by the owning YAML");
}
}
if (observeActionRaw === "command" && commandType === "validateWorkbenchTraceReadability") {
if (spec.webProbe?.workbenchTraceReadability?.enabled !== true) {
throw new Error("owning YAML 未启用 validateWorkbenchTraceReadability");
}
}
return {
action: "observe",
observeAction: observeActionRaw,
@@ -701,6 +706,7 @@ export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbe
|| value === "sendPrompt"
|| value === "validateRealtimeFanout"
|| value === "validateWorkbenchKafkaDebugReplay"
|| value === "validateWorkbenchTraceReadability"
|| value === "steer"
|| value === "cancel"
|| value === "selectProvider"
@@ -736,7 +742,7 @@ export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbe
|| value === "mark"
|| value === "stop"
) return value;
throw new Error(`web-probe observe command --type must be login, loginAccount, logout, listSessions, switchSessions, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, validateRealtimeFanout, validateWorkbenchKafkaDebugReplay, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, closeMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, performanceCapture, screenshot, mark, or stop; got ${value}`);
throw new Error(`web-probe observe command --type must be login, loginAccount, logout, listSessions, switchSessions, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, validateRealtimeFanout, validateWorkbenchKafkaDebugReplay, validateWorkbenchTraceReadability, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, closeMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, performanceCapture, screenshot, mark, or stop; got ${value}`);
}
export function parseWebProbeBrowserProxyMode(value: string | undefined): WebProbeBrowserProxyMode {