From 2dd76ece9c09725cf0a338e26b86dfd444c028e9 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 2 Jul 2026 19:55:10 +0000 Subject: [PATCH] fix(cli): recover web observe collect JSON dumps --- scripts/src/hwlab-node-web-observe-render.ts | 15 +- .../web-probe-observe-collect.test.ts | 128 ++++++++++++++++++ .../hwlab-node/web-probe-observe-collect.ts | 109 +++++++++++++++ scripts/src/hwlab-node/web-probe-observe.ts | 53 +------- 4 files changed, 255 insertions(+), 50 deletions(-) create mode 100644 scripts/src/hwlab-node/web-probe-observe-collect.test.ts create mode 100644 scripts/src/hwlab-node/web-probe-observe-collect.ts diff --git a/scripts/src/hwlab-node-web-observe-render.ts b/scripts/src/hwlab-node-web-observe-render.ts index f9c55332..1d1dd642 100644 --- a/scripts/src/hwlab-node-web-observe-render.ts +++ b/scripts/src/hwlab-node-web-observe-render.ts @@ -317,6 +317,8 @@ function renderWebObserveCollectTable(value: Record): 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 { 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"); } diff --git a/scripts/src/hwlab-node/web-probe-observe-collect.test.ts b/scripts/src/hwlab-node/web-probe-observe-collect.test.ts new file mode 100644 index 00000000..c9ba5407 --- /dev/null +++ b/scripts/src/hwlab-node/web-probe-observe-collect.test.ts @@ -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; + expect(collect.view).toBe("performance-summary"); + expect(collect.renderedText).toContain("payloads=2"); + const recovery = payload.stdoutRecovery as Record; + 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, + }; +} diff --git a/scripts/src/hwlab-node/web-probe-observe-collect.ts b/scripts/src/hwlab-node/web-probe-observe-collect.ts new file mode 100644 index 00000000..7341f66d --- /dev/null +++ b/scripts/src/hwlab-node/web-probe-observe-collect.ts @@ -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 | 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, + result: CommandResult, +): Record { + 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): 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 { + 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; +} diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts index ff8c17cb..2a594492 100644 --- a/scripts/src/hwlab-node/web-probe-observe.ts +++ b/scripts/src/hwlab-node/web-probe-observe.ts @@ -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 | 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 | RenderedCliResult { const analyzerB64 = Buffer.from(nodeWebObserveAnalyzerSource(), "utf8").toString("base64"); const alertThresholds = nodeWebProbeAlertThresholds(spec);