fix(cli): recover web observe collect JSON dumps

This commit is contained in:
Codex
2026-07-02 19:55:10 +00:00
parent 146ecc5e82
commit 2dd76ece9c
4 changed files with 255 additions and 50 deletions
+14 -1
View File
@@ -317,6 +317,8 @@ function renderWebObserveCollectTable(value: Record<string, unknown>): string {
];
if (collect === null) {
const stdoutRecovery = record(value.stdoutRecovery) ?? record(result.stdoutRecovery);
const artifact = record(stdoutRecovery?.artifact);
lines.push(
"Collect command:",
webObserveTable(["REQUESTED", "REASON", "EXIT", "TIMED_OUT", "STDOUT_BYTES", "STDOUT_TAIL", "STDERR"], [[
@@ -329,8 +331,19 @@ function renderWebObserveCollectTable(value: Record<string, unknown>): string {
webObserveShort(webObserveText(result.stderr), 160),
]]),
"",
"Stdout recovery:",
webObserveTable(["REQUESTED_TYPE", "STDOUT_KIND", "SOURCE", "FALLBACK_REASON", "DUMP_PATH", "ARTIFACT_PATH", "NEXT"], [[
webObserveShort(webObserveText(stdoutRecovery?.requestedStdoutType), 42),
webObserveShort(webObserveText(stdoutRecovery?.stdoutKind), 28),
webObserveShort(webObserveText(stdoutRecovery?.source), 20),
webObserveShort(webObserveText(stdoutRecovery?.fallbackReason), 40),
webObserveShort(webObserveText(stdoutRecovery?.dumpPath), 72),
webObserveShort(webObserveText(artifact?.path ?? artifact?.requestedPath), 72),
webObserveShort(webObserveText(artifact?.nextCommand), 96),
]]),
"",
"Disclosure:",
" collect stdout was not valid JSON; fix the collect command or rerun with a narrower --file after the root cause is visible.",
" collect stdout could not be recovered as a valid collect contract; inspect dumpPath or artifact path when present, otherwise rerun with a narrower --file.",
);
return lines.join("\n");
}
@@ -0,0 +1,128 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, test } from "bun:test";
import type { CommandResult } from "../command";
import type { NodeWebProbeObserveOptions } from "./entry";
import { buildNodeWebProbeObserveCollectPayload } from "./web-probe-observe-collect";
describe("web-probe observe collect child JSON recovery", () => {
test("recovers collect JSON from trans stdout dump", () => {
const dir = join(tmpdir(), `unidesk-collect-recovery-${Date.now()}-${process.pid}`);
mkdirSync(dir, { recursive: true });
const dumpPath = join(dir, "stdout.json");
writeFileSync(dumpPath, JSON.stringify({
ok: true,
command: "web-probe-observe collect",
view: "performance-summary",
stateDir: "/remote/state",
renderedText: "WEB PERFORMANCE SUMMARY\npayloads=2 events=3 groups=1",
valuesRedacted: true,
}));
const summary = {
stdout: {
stream: "stdout",
truncated: true,
dumpPath,
valuesRedacted: true,
},
valuesRedacted: true,
};
const payload = buildNodeWebProbeObserveCollectPayload(collectOptions(), { workspace: "/workspace" }, {
command: ["trans", "JD01:/workspace", "sh"],
cwd: "/repo",
exitCode: 0,
stdout: "tail only WEB PERFORMANCE SUMMARY",
stderr: `UNIDESK_SSH_TRUNCATION_SUMMARY ${JSON.stringify(summary)}\n`,
signal: null,
timedOut: false,
} satisfies CommandResult);
expect(payload.ok).toBe(true);
expect(payload.status).toBe("collected");
const collect = payload.collect as Record<string, unknown>;
expect(collect.view).toBe("performance-summary");
expect(collect.renderedText).toContain("payloads=2");
const recovery = payload.stdoutRecovery as Record<string, unknown>;
expect(recovery.stdoutKind).toBe("ssh-truncation-summary");
expect(recovery.dumpPath).toBe(dumpPath);
expect(recovery.source).toBe("dump");
});
});
function collectOptions(): NodeWebProbeObserveOptions {
return {
action: "observe",
observeAction: "collect",
id: "webobs-test",
node: "JD01",
lane: "v03",
url: "https://example.test",
targetPath: "/",
viewport: "1920x1080",
browserProxyMode: "auto",
sampleIntervalMs: 1000,
screenshotIntervalMs: 0,
observerRefreshIntervalMs: 1000,
maxSamples: 10,
maxRunSeconds: 60,
commandTimeoutSeconds: 30,
waitMs: 0,
tailLines: 20,
maxFiles: 100,
gcKeepHours: 24,
gcLimit: 10,
confirm: false,
dryRun: false,
collectView: "performance-summary",
collectFile: null,
collectFinding: null,
collectGrep: null,
collectTraceId: null,
collectSampleSeq: null,
collectTimestamp: null,
collectTurn: null,
collectCommandId: null,
collectWindowMs: null,
analyzeArchivePrefix: null,
analyzeTailSamples: null,
full: false,
raw: false,
compactRaw: false,
stateDir: null,
jobId: null,
force: false,
commandType: null,
commandText: null,
commandPath: null,
commandLabel: null,
commandSessionId: null,
commandProvider: null,
commandAfterRound: null,
commandSeverity: null,
commandAlternateSessionStrategy: null,
commandExpectedSentinelRange: null,
commandExpectedActionWaitMs: null,
commandDurationMs: null,
commandRequireComposerReady: false,
commandWaitProjectManagementReady: false,
commandFindingId: null,
commandBlocking: null,
commandAccountId: null,
commandFromAccountId: null,
commandToAccountId: null,
commandSourceId: null,
commandFileRef: null,
commandFilename: null,
commandTaskRef: null,
commandTaskId: null,
commandField: null,
commandLink: null,
commandTitle: null,
commandBody: null,
commandStatus: null,
commandHwpodId: null,
commandNodeId: null,
commandWorkspaceRoot: null,
commandRoot: null,
};
}
@@ -0,0 +1,109 @@
// SPEC: PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer.
// Responsibility: web-probe observe collect action and child JSON recovery.
import type { CommandResult } from "../command";
import { resolveCliChildJsonCommandResult } from "../cli-child-json-recovery";
import { nodeWebObserveCollectViewNodeScript } from "../hwlab-node-web-observe-collect";
import { withWebObserveCollectRendered } from "../hwlab-node-web-observe-render";
import { buildWebObserveWrapperForObserveOptions } from "../hwlab-node-web-observe-wrapper";
import type { HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { RenderedCliResult } from "../output";
import type { NodeWebProbeObserveOptions } from "./entry";
import { runTransWorkspaceStdinScript } from "./public-exposure";
import { compactCommandResult, compactCommandResultWithStdoutTail } from "./utils";
import { webObserveCommandLabel, webObserveIdFromOptions, nodeWebObserveResolveStateDirShell } from "./web-observe-render";
import { nodeWebObserveCollectNodeScript } from "./web-observe-scripts";
import { compactObserveCollectForRaw } from "./web-observe-collect-compact";
export function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec): Record<string, unknown> | RenderedCliResult {
const collectScript = options.collectView === "files"
? nodeWebObserveCollectNodeScript(options.maxFiles, options.collectFile, options.collectFinding, options.collectGrep)
: nodeWebObserveCollectViewNodeScript({
maxFiles: options.maxFiles,
view: options.collectView,
traceId: options.collectTraceId,
sampleSeq: options.collectSampleSeq,
timestamp: options.collectTimestamp,
turn: options.collectTurn,
commandId: options.collectCommandId,
windowMs: options.collectWindowMs,
});
const script = [
"set -eu",
nodeWebObserveResolveStateDirShell(options),
collectScript,
].join("\n");
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
const payload = buildNodeWebProbeObserveCollectPayload(options, spec, result);
return options.raw ? payload : withWebObserveCollectRendered(payload);
}
export function buildNodeWebProbeObserveCollectPayload(
options: NodeWebProbeObserveOptions,
spec: Pick<HwlabRuntimeLaneSpec, "workspace">,
result: CommandResult,
): Record<string, unknown> {
const stdoutResolution = resolveCliChildJsonCommandResult({
result,
requestedStdoutType: "web-probe-observe-collect",
acceptParsed: isWebObserveCollectJsonContract,
});
const collect = stdoutResolution.parsed;
const compactRaw = options.raw && options.compactRaw;
const degradedReason = collect === null
? collectDegradedReason(stdoutResolution.diagnostics)
: null;
return {
ok: result.exitCode === 0 && collect !== null && collect.ok !== false,
status: result.exitCode === 0 && collect !== null ? "collected" : "blocked",
command: webObserveCommandLabel("collect", options),
id: webObserveIdFromOptions(options),
node: options.node,
lane: options.lane,
workspace: spec.workspace,
view: options.collectView,
requestedFile: options.collectFile,
requestedGrep: options.collectGrep,
requestedCommandId: options.collectCommandId,
requestedWindowMs: options.collectWindowMs,
degradedReason,
stdoutRecovery: stdoutResolution.diagnostics,
collect: compactRaw ? compactObserveCollectForRaw(collect) : collect,
wrapper: compactRaw
? { mode: "wrapper-only", action: "collect", node: options.node, lane: options.lane, id: webObserveIdFromOptions(options), stateDir: options.stateDir, valuesRedacted: true }
: buildWebObserveWrapperForObserveOptions("collect", options, spec.workspace),
result: compactRaw
? { exitCode: result.exitCode, timedOut: result.timedOut, stdoutBytes: Buffer.byteLength(result.stdout), stderrBytes: Buffer.byteLength(result.stderr), stdoutRecovery: stdoutResolution.diagnostics }
: collect === null
? { ...compactCommandResultWithStdoutTail(result), stdoutRecovery: stdoutResolution.diagnostics }
: compactCommandResult(result),
valuesRedacted: true,
};
}
export function isWebObserveCollectJsonContract(value: Record<string, unknown>): boolean {
const command = stringOrNull(value.command);
const view = stringOrNull(value.view);
const stateDir = stringOrNull(value.stateDir);
if (value.ok === false) {
return command === "web-probe-observe collect"
|| view !== null
|| stringOrNull(value.reason) !== null
|| stringOrNull(value.error) !== null;
}
return command === "web-probe-observe collect"
&& view !== null
&& stateDir !== null;
}
function collectDegradedReason(diagnostics: Record<string, unknown>): string {
const fallbackReason = stringOrNull(diagnostics.fallbackReason);
if (fallbackReason === "stdout-json-contract-invalid") return "collect-stdout-json-contract-invalid";
if (fallbackReason === "stdout-trans-truncated" || fallbackReason === "stdout-ssh-truncated") return "collect-stdout-dump-unavailable";
if (fallbackReason === "stdout-dump-wrapper-unreadable") return "collect-stdout-dump-unreadable";
if (fallbackReason === "stdout-empty") return "collect-stdout-empty";
return "collect-stdout-json-unavailable";
}
function stringOrNull(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
+4 -49
View File
@@ -19,8 +19,8 @@ import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec,
import { nodeWebProbeScriptRunnerSource } from "../hwlab-node-web-probe-runner-source";
import { nodeWebObserveAnalyzerSource } from "../hwlab-node-web-observe-analyzer-source";
import { nodeWebObserveRunnerSource } from "../hwlab-node-web-observe-runner-source";
import { nodeWebObserveCollectViewNodeScript, parseNodeWebProbeObserveCollectView, type NodeWebProbeObserveCollectView } from "../hwlab-node-web-observe-collect";
import { withWebObserveCollectRendered, withWebObserveCommandRendered, withWebObserveStatusRendered } from "../hwlab-node-web-observe-render";
import { parseNodeWebProbeObserveCollectView, type NodeWebProbeObserveCollectView } from "../hwlab-node-web-observe-collect";
import { withWebObserveCommandRendered, withWebObserveStatusRendered } from "../hwlab-node-web-observe-render";
import { buildWebObserveWrapperForObserveOptions, webObserveWrapperStateDirFromStatus } from "../hwlab-node-web-observe-wrapper";
import { renderWebObserveWrapperContract } from "../hwlab-node-web-observe-wrapper-render";
import { runWebProbeSentinelCommand, type WebProbeSentinelDashboardAction, type WebProbeSentinelOptions, type WebProbeSentinelReportView } from "../hwlab-node-web-sentinel-cicd";
@@ -35,9 +35,9 @@ import type { BootstrapAdminPasswordMaterial, NodeWebProbeObserveCommandType, No
import { runTransWorkspaceStdinScript, runtimeSecretSpec } from "./public-exposure";
import { transPath } from "./runtime-common";
import { assertLane, assertNodeId, compactCommandResult, compactCommandResultRedacted, compactCommandResultWithStdoutTail, nullableRecord, optionValue, parseJsonObject, positiveIntegerOption, record, requiredOption, shellQuote } from "./utils";
import { runNodeWebProbeObserveCollect } from "./web-probe-observe-collect";
import { nodeWebObserveResolveStateDirShell, recoverWebObserveAnalyzeTurnDetails, renderWebObserveStartResult, renderWebProbeRunResult, upsertWebObserveIndexEntry, webObserveCommandLabel, webObserveIdFromOptions, webObserveIdFromStatus, webObserveIndexEntryFromOptions, webObserveNextCommands, withWebObserveAnalyzeRendered, withWebObserveShortcuts } from "./web-observe-render";
import { commandSummaryForOutput, isSafeWebObserveArchivePrefix, isSafeWebObserveCollectFile, isSafeWebObserveFindingId, isSafeWebObserveJobId, isSafeWebObserveStateDir, isSafeWebObserveTraceId, nodeWebObserveCollectNodeScript, nodeWebObserveForceStopNodeScript, nodeWebObserveStatusNodeScript, nodeWebObserveWaitCommandShell, runNodeWebProbeScript, safeWebObserveSegment, safeWebObserveTargetSegment } from "./web-observe-scripts";
import { compactObserveCollectForRaw } from "./web-observe-collect-compact";
import { commandSummaryForOutput, isSafeWebObserveArchivePrefix, isSafeWebObserveCollectFile, isSafeWebObserveFindingId, isSafeWebObserveJobId, isSafeWebObserveStateDir, isSafeWebObserveTraceId, nodeWebObserveForceStopNodeScript, nodeWebObserveStatusNodeScript, nodeWebObserveWaitCommandShell, runNodeWebProbeScript, safeWebObserveSegment, safeWebObserveTargetSegment } from "./web-observe-scripts";
import { displayRepoPath, readBootstrapAdminPasswordMaterial, sleepSync } from "./web-probe";
export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSentinelOptions {
@@ -2022,51 +2022,6 @@ export function runNodeWebProbeObserveForceStop(
return options.raw ? payloadResult : withWebObserveCommandRendered(payloadResult);
}
export function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec): Record<string, unknown> | RenderedCliResult {
const collectScript = options.collectView === "files"
? nodeWebObserveCollectNodeScript(options.maxFiles, options.collectFile, options.collectFinding, options.collectGrep)
: nodeWebObserveCollectViewNodeScript({
maxFiles: options.maxFiles,
view: options.collectView,
traceId: options.collectTraceId,
sampleSeq: options.collectSampleSeq,
timestamp: options.collectTimestamp,
turn: options.collectTurn,
commandId: options.collectCommandId,
windowMs: options.collectWindowMs,
});
const script = [
"set -eu",
nodeWebObserveResolveStateDirShell(options),
collectScript,
].join("\n");
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
const collect = parseJsonObject(result.stdout);
const compactRaw = options.raw && options.compactRaw;
const payload = {
ok: result.exitCode === 0 && collect !== null && collect.ok !== false,
status: result.exitCode === 0 && collect !== null ? "collected" : "blocked",
command: webObserveCommandLabel("collect", options),
id: webObserveIdFromOptions(options),
node: options.node,
lane: options.lane,
workspace: spec.workspace,
view: options.collectView,
requestedFile: options.collectFile,
requestedGrep: options.collectGrep,
requestedCommandId: options.collectCommandId,
requestedWindowMs: options.collectWindowMs,
degradedReason: collect === null ? "collect-json-parse-failed" : null,
collect: compactRaw ? compactObserveCollectForRaw(collect) : collect,
wrapper: compactRaw
? { mode: "wrapper-only", action: "collect", node: options.node, lane: options.lane, id: webObserveIdFromOptions(options), stateDir: options.stateDir, valuesRedacted: true }
: buildWebObserveWrapperForObserveOptions("collect", options, spec.workspace),
result: compactRaw ? { exitCode: result.exitCode, timedOut: result.timedOut, stdoutBytes: Buffer.byteLength(result.stdout), stderrBytes: Buffer.byteLength(result.stderr) } : collect === null ? compactCommandResultWithStdoutTail(result) : compactCommandResult(result),
valuesRedacted: true,
};
return options.raw ? payload : withWebObserveCollectRendered(payload);
}
export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec): Record<string, unknown> | RenderedCliResult {
const analyzerB64 = Buffer.from(nodeWebObserveAnalyzerSource(), "utf8").toString("base64");
const alertThresholds = nodeWebProbeAlertThresholds(spec);