Merge pull request #1690 from pikasTech/fix/web-probe-exact-command-status-render
fix: 暴露 web-probe exact command 终态证据
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user