fix(web-probe): expose exact command artifacts

This commit is contained in:
Codex
2026-07-10 12:06:31 +02:00
parent 70bf798d46
commit 3a26e77e25
4 changed files with 226 additions and 5 deletions
+18 -1
View File
@@ -37,6 +37,10 @@ function renderWebObserveStatusTable(value: Record<string, unknown>): string {
const heartbeat = record(observer?.heartbeat);
const diagnostics = record(observer?.diagnostics) ?? record(value.diagnostics);
const commands = record(observer?.commands);
const exactCommand = record(observer?.exactCommand);
const exactResult = record(exactCommand?.result);
const exactError = record(exactCommand?.error);
const exactErrorDetails = record(exactError?.details);
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) : [];
@@ -158,6 +162,19 @@ function renderWebObserveStatusTable(value: Record<string, unknown>): string {
]]),
"",
] : []),
...(Object.keys(exactCommand).length > 0 ? [
"Exact command:",
webObserveTable(["COMMAND", "TYPE", "PHASE", "OK", "STATUS", "REPORT_SHA", "DETAIL"], [[
webObserveShort(webObserveText(exactCommand.commandId), 40),
exactCommand.type,
exactCommand.phase,
exactCommand.ok,
exactResult.status,
webObserveShort(webObserveText(exactResult.reportSha256 ?? exactErrorDetails.reportSha256), 32),
webObserveShort(webObserveText(exactError.message ?? exactResult.reportPath ?? exactErrorDetails.reportPath), 96),
]]),
"",
] : []),
"Recent samples:",
webObserveTable(["SEQ", "TS", "PATH", "ROUTE_SESSION", "ACTIVE_SESSION", "MSG", "TRACE"], samples.length > 0 ? samples.map((sample) => [
sample.seq,
@@ -255,7 +272,7 @@ function renderWebObserveCommandTable(value: Record<string, unknown>): string {
] : []),
"",
"Next:",
` bun scripts/cli.ts web-probe observe status ${id}`,
` bun scripts/cli.ts web-probe observe status ${id} --command-id ${webObserveText(value.commandId)}`,
` bun scripts/cli.ts web-probe observe analyze ${id}`,
` bun scripts/cli.ts web-probe observe collect ${id} --view turn-summary --command-id ${webObserveText(value.commandId)}`,
"",
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import { withWebObserveCommandRendered } from "../hwlab-node-web-observe-render";
import { withWebObserveCommandRendered, withWebObserveStatusRendered } from "../hwlab-node-web-observe-render";
import { renderWebProbeScriptResult } from "./web-observe-render";
import { webProbeScriptCommandGovernance } from "./web-observe-scripts";
@@ -21,9 +21,51 @@ test("observe command renderer separates control completion from async turn term
assert.match(text, /CONTROL.*TURN_SUBMIT.*TURN_TERMINAL/u);
assert.match(text, /completed.*accepted.*not-observed.*trc_fixture/u);
assert.match(text, /does not imply the asynchronous business turn reached a terminal state/u);
assert.match(text, /observe status webobs-fixture --command-id cmd-fixture/u);
assert.match(text, /--view turn-summary --command-id cmd-fixture/u);
});
test("observe status renderer exposes the exact command phase and failed report evidence", () => {
const rendered = withWebObserveStatusRendered({
ok: true,
status: "running",
command: "web-probe observe status",
id: "webobs-fixture",
node: "NC01",
lane: "v03",
observer: {
processAlive: true,
manifest: { baseUrl: "https://hwlab.example.test", targetPath: "/workbench" },
heartbeat: { status: "running", sampleSeq: 12, commandSeq: 3, updatedAt: "2026-07-10T01:00:01.000Z" },
diagnostics: { effectiveLiveness: "alive", heartbeatStale: false },
commands: { pendingCount: 0, processingCount: 0, abandonedCount: 0, failedCount: 1 },
exactCommand: {
commandId: "cmd-fixture",
type: "validateRealtimeFanout",
phase: "failed",
ok: false,
error: {
message: "terminal event arrived before subscriber disconnect",
details: {
reportPath: "artifacts/realtime-fanout/cmd-fixture/report.json",
reportSha256: "sha256:fixture-report",
},
},
},
tails: { samples: [], control: [], network: [] },
},
next: {
status: "bun scripts/cli.ts web-probe observe status webobs-fixture",
},
});
const text = String(rendered.renderedText);
assert.match(text, /Exact command:/u);
assert.match(text, /cmd-fixture.*validateRealtimeFanout.*failed.*false/u);
assert.match(text, /sha256:fixture-report/u);
assert.match(text, /terminal event arrived before subscriber disconnect/u);
});
test("web-probe script render warns to promote repeated scripts into commands", () => {
const commandPromotionHint = {
schemaVersion: "unidesk.web-probe.command-promotion.v1",
@@ -5,6 +5,7 @@ import { describe, expect, test } from "bun:test";
import type { CommandResult } from "../command";
import type { NodeWebProbeObserveOptions } from "./entry";
import { buildNodeWebProbeObserveCollectPayload } from "./web-probe-observe-collect";
import { withWebObserveCollectRendered } from "../hwlab-node-web-observe-render";
describe("web-probe observe collect child JSON recovery", () => {
test("recovers collect JSON from trans stdout dump", () => {
@@ -80,6 +81,60 @@ test("propagates structured turn-summary not-found status without treating it as
expect(collect.rows).toEqual([]);
});
test("collects an exact failed command artifact without treating artifact failure as collect failure", () => {
const options = collectOptions();
options.collectView = "files";
options.collectFile = "commands/failed/cmd-fixture.json";
const failedArtifact = {
ok: false,
commandId: "cmd-fixture",
type: "validateRealtimeFanout",
failedAt: "2026-07-10T12:00:00.000Z",
error: {
name: "Error",
message: "terminal event arrived before subscriber disconnect",
details: {
reportPath: "artifacts/realtime-fanout/cmd-fixture/report.json",
reportSha256: "sha256:fixture-report",
sessionToken: "must-not-render",
},
},
failureSample: { ok: true, sampleSeq: 22, valuesRedacted: true },
};
const payload = buildNodeWebProbeObserveCollectPayload(options, { workspace: "/workspace" }, {
command: ["trans", "NC01:/workspace", "sh"],
cwd: "/repo",
exitCode: 0,
stdout: JSON.stringify(failedArtifact),
stderr: "",
signal: null,
timedOut: false,
} satisfies CommandResult);
expect(payload.ok).toBe(true);
expect(payload.status).toBe("collected");
expect(payload.degradedReason).toBeNull();
const recovery = payload.stdoutRecovery as Record<string, unknown>;
expect(recovery.source).toBe("stdout");
expect(recovery.stdoutContractAccepted).toBe(true);
const collect = payload.collect as Record<string, any>;
expect(collect.ok).toBe(true);
expect(collect.artifactOutcome).toEqual({
ok: false,
commandId: "cmd-fixture",
type: "validateRealtimeFanout",
bucket: "failed",
valuesRedacted: true,
});
expect(collect.file.jsonContent.commandId).toBe("cmd-fixture");
expect(collect.file.jsonContent.error.message).toBe("terminal event arrived before subscriber disconnect");
expect(collect.file.jsonContent.error.details.sessionToken).toBe("[redacted]");
const rendered = withWebObserveCollectRendered(payload);
expect(String(rendered.renderedText)).toContain("cmd-fixture");
expect(String(rendered.renderedText)).toContain("terminal event arrived before subscriber disconnect");
});
function collectOptions(): NodeWebProbeObserveOptions {
return {
action: "observe",
@@ -1,5 +1,6 @@
// SPEC: PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer.
// Responsibility: web-probe observe collect action and child JSON recovery.
import { createHash } from "node:crypto";
import type { CommandResult } from "../command";
import { resolveCliChildJsonCommandResult } from "../cli-child-json-recovery";
import { nodeWebObserveCollectViewNodeScript } from "../hwlab-node-web-observe-collect";
@@ -45,9 +46,9 @@ export function buildNodeWebProbeObserveCollectPayload(
const stdoutResolution = resolveCliChildJsonCommandResult({
result,
requestedStdoutType: "web-probe-observe-collect",
acceptParsed: isWebObserveCollectJsonContract,
acceptParsed: (value) => isWebObserveCollectJsonContract(value, options.collectFile),
});
const collect = stdoutResolution.parsed;
const collect = normalizeWebObserveCollectResult(stdoutResolution.parsed, options.collectFile, result.stdout, options.stateDir);
const compactRaw = options.raw && options.compactRaw;
const degradedReason = collect === null
? collectDegradedReason(stdoutResolution.diagnostics)
@@ -85,10 +86,11 @@ export function buildNodeWebProbeObserveCollectPayload(
};
}
export function isWebObserveCollectJsonContract(value: Record<string, unknown>): boolean {
export function isWebObserveCollectJsonContract(value: Record<string, unknown>, requestedFile: string | null = null): boolean {
const command = stringOrNull(value.command);
const view = stringOrNull(value.view);
const stateDir = stringOrNull(value.stateDir);
if (commandArtifactBucket(value, requestedFile) !== null) return true;
if (value.ok === false) {
return command === "web-probe-observe collect"
|| view !== null
@@ -100,6 +102,107 @@ export function isWebObserveCollectJsonContract(value: Record<string, unknown>):
&& stateDir !== null;
}
function normalizeWebObserveCollectResult(
value: Record<string, unknown> | null,
requestedFile: string | null,
stdout: string,
stateDir: string | null,
): Record<string, unknown> | null {
if (value === null) return null;
const bucket = commandArtifactBucket(value, requestedFile);
if (bucket === null || requestedFile === null) return value;
const jsonContent = compactCommandArtifact(value);
return {
ok: true,
command: "web-probe-observe collect",
stateDir,
mode: "file",
requestedFile,
resolvedFile: requestedFile,
fallbackReason: "terminal-command-artifact-stdout-normalized",
file: {
relative: requestedFile,
requestedRelative: requestedFile,
resolvedRelative: requestedFile,
fallbackReason: "terminal-command-artifact-stdout-normalized",
byteCount: Buffer.byteLength(stdout),
sha256: `sha256:${createHash("sha256").update(stdout).digest("hex")}`,
truncated: false,
contentTruncated: false,
jsonlTail: null,
jsonSummary: {
topLevelKeys: Object.keys(value).slice(0, 24),
commandArtifactBucket: bucket,
artifactOk: value.ok,
},
jsonContent,
grep: null,
lineCount: stdout.split(/\r?\n/u).filter(Boolean).length,
},
artifactOutcome: {
ok: value.ok,
commandId: value.commandId,
type: value.type,
bucket,
valuesRedacted: true,
},
valuesRedacted: true,
};
}
function commandArtifactBucket(value: Record<string, unknown>, requestedFile: string | null): "done" | "failed" | "abandoned" | null {
if (requestedFile === null) return null;
const match = requestedFile.match(/^commands\/(done|failed|abandoned)\/([A-Za-z0-9_.-]+)\.json$/u);
if (match === null || stringOrNull(value.commandId) !== match[2] || stringOrNull(value.type) === null) return null;
if (match[1] === "done") {
return value.ok === true && stringOrNull(value.completedAt) !== null && isRecord(value.result) ? "done" : null;
}
if (match[1] === "failed") {
return value.ok === false && stringOrNull(value.failedAt) !== null && isRecord(value.error) ? "failed" : null;
}
return value.ok === false && stringOrNull(value.abandonedAt) !== null && stringOrNull(value.reason) !== null ? "abandoned" : null;
}
function compactCommandArtifact(value: Record<string, unknown>): Record<string, unknown> {
return {
ok: value.ok,
commandId: value.commandId,
type: value.type,
completedAt: value.completedAt ?? null,
failedAt: value.failedAt ?? null,
abandonedAt: value.abandonedAt ?? null,
reason: boundedRedactedValue(value.reason),
result: boundedRedactedValue(value.result),
error: boundedRedactedValue(value.error),
failureSample: boundedRedactedValue(value.failureSample),
valuesRedacted: true,
};
}
function boundedRedactedValue(value: unknown, depth = 0): unknown {
if (value === null || value === undefined || typeof value === "number" || typeof value === "boolean") return value ?? null;
if (typeof value === "string") return value.slice(0, 360);
if (depth >= 4) return Array.isArray(value)
? { arrayLength: value.length }
: isRecord(value)
? { keys: Object.keys(value).slice(0, 20) }
: String(value).slice(0, 180);
if (Array.isArray(value)) {
const items = value.slice(0, 12).map((item) => boundedRedactedValue(item, depth + 1));
return value.length > 12 ? [...items, { omitted: value.length - 12 }] : items;
}
if (!isRecord(value)) return String(value).slice(0, 180);
const output: Record<string, unknown> = {};
const keys = Object.keys(value).slice(0, 40);
for (const key of keys) {
output[key] = /token|secret|password|passwd|authorization|cookie|api[-_]?key|session/iu.test(key)
? "[redacted]"
: boundedRedactedValue(value[key], depth + 1);
}
if (Object.keys(value).length > keys.length) output.__omittedKeys = Object.keys(value).length - keys.length;
return output;
}
function collectDegradedReason(diagnostics: Record<string, unknown>): string {
const fallbackReason = stringOrNull(diagnostics.fallbackReason);
if (fallbackReason === "stdout-json-contract-invalid") return "collect-stdout-json-contract-invalid";
@@ -112,3 +215,7 @@ function collectDegradedReason(diagnostics: Record<string, unknown>): string {
function stringOrNull(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}