fix(cli): recover web observe collect JSON dumps
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user