Merge pull request #1466 from pikasTech/fix/1465-cli-child-json-recovery
fix(cli): recover child json from dumps and artifacts
This commit is contained in:
@@ -0,0 +1,152 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { test } from "bun:test";
|
||||||
|
|
||||||
|
import { resolveCliChildJsonObject } from "./cli-child-json-recovery";
|
||||||
|
|
||||||
|
test("child JSON recovery reads full JSON from CLI stdout dump wrapper", async () => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), "unidesk-cli-child-json-dump-"));
|
||||||
|
const dumpPath = join(dir, "dump.json");
|
||||||
|
await writeFile(dumpPath, JSON.stringify({ ok: true, data: { contract: "full-json" } }) + "\n");
|
||||||
|
|
||||||
|
const wrapper = {
|
||||||
|
ok: true,
|
||||||
|
command: "fixture",
|
||||||
|
data: {
|
||||||
|
outputTruncated: true,
|
||||||
|
reason: "stdout-json-bytes-exceeded-threshold",
|
||||||
|
dump: { path: dumpPath },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const resolved = resolveCliChildJsonObject({
|
||||||
|
stdout: JSON.stringify(wrapper),
|
||||||
|
requestedStdoutType: "fixture JSON",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolved.source, "dump");
|
||||||
|
assert.equal(resolved.parsed?.ok, true);
|
||||||
|
assert.deepEqual(resolved.parsed?.data, { contract: "full-json" });
|
||||||
|
assert.equal(resolved.diagnostics.stdoutKind, "dump-wrapper");
|
||||||
|
assert.equal(resolved.diagnostics.dumpPath, dumpPath);
|
||||||
|
assert.equal(resolved.diagnostics.dumpReadOk, true);
|
||||||
|
assert.equal(resolved.diagnostics.dumpJsonOk, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("child JSON recovery falls back to artifact after trans truncation summary", () => {
|
||||||
|
const summary = {
|
||||||
|
code: "ssh-truncation-summary",
|
||||||
|
exitCode: 0,
|
||||||
|
timedOut: false,
|
||||||
|
stdout: {
|
||||||
|
stream: "stdout",
|
||||||
|
thresholdBytes: 10240,
|
||||||
|
observedBytesAtTruncation: 165000,
|
||||||
|
forwardedBytes: 10240,
|
||||||
|
dumpPath: null,
|
||||||
|
dumpError: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const resolved = resolveCliChildJsonObject({
|
||||||
|
stdout: `UNIDESK_SSH_TRUNCATION_SUMMARY ${JSON.stringify(summary)}\n`,
|
||||||
|
requestedStdoutType: "web-probe observe analyze compact JSON",
|
||||||
|
artifactFallback: {
|
||||||
|
path: "analysis/report.json",
|
||||||
|
nextCommand: "bun scripts/cli.ts web-probe observe collect webobs-fixture --view files --file analysis/report.json",
|
||||||
|
read: () => ({ ok: true, value: { ok: true, reportJsonPath: "analysis/report.json" }, reason: "analysis-artifact-contract" }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolved.source, "artifact");
|
||||||
|
assert.equal(resolved.parsed?.ok, true);
|
||||||
|
assert.equal(resolved.diagnostics.stdoutKind, "ssh-truncation-summary");
|
||||||
|
assert.equal(resolved.diagnostics.fallbackReason, "stdout-trans-truncated");
|
||||||
|
const artifact = resolved.diagnostics.artifact as Record<string, unknown>;
|
||||||
|
assert.equal(artifact.ok, true);
|
||||||
|
assert.equal(artifact.nextCommand, "bun scripts/cli.ts web-probe observe collect webobs-fixture --view files --file analysis/report.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("child JSON recovery falls back to artifact when stdout is not JSON", () => {
|
||||||
|
const resolved = resolveCliChildJsonObject({
|
||||||
|
stdout: "remote helper wrote a bounded text summary instead of JSON",
|
||||||
|
requestedStdoutType: "web-probe observe analyze compact JSON",
|
||||||
|
artifactFallback: {
|
||||||
|
path: "analysis/report.json",
|
||||||
|
nextCommand: "collect report",
|
||||||
|
read: () => ({ ok: true, value: { ok: true, counts: { network: 9 }, reportJsonPath: "analysis/report.json" } }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolved.source, "artifact");
|
||||||
|
assert.equal(resolved.parsed?.ok, true);
|
||||||
|
assert.deepEqual(resolved.parsed?.counts, { network: 9 });
|
||||||
|
assert.equal(resolved.diagnostics.stdoutKind, "non-json");
|
||||||
|
assert.equal(resolved.diagnostics.fallbackReason, "stdout-not-json");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("child JSON recovery rejects ok-only stdout contract and uses artifact", () => {
|
||||||
|
const resolved = resolveCliChildJsonObject({
|
||||||
|
stdout: JSON.stringify({ ok: true }),
|
||||||
|
requestedStdoutType: "web-probe observe analyze compact JSON",
|
||||||
|
acceptParsed: (value) => typeof value.reportJsonPath === "string" || typeof value.counts === "object",
|
||||||
|
artifactFallback: {
|
||||||
|
path: "analysis/report.json",
|
||||||
|
nextCommand: "collect report",
|
||||||
|
read: () => ({ ok: true, value: { ok: true, reportJsonPath: "analysis/report.json", counts: { network: 9 } } }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolved.source, "artifact");
|
||||||
|
assert.equal(resolved.parsed?.reportJsonPath, "analysis/report.json");
|
||||||
|
assert.deepEqual(resolved.parsed?.counts, { network: 9 });
|
||||||
|
assert.equal(resolved.diagnostics.stdoutKind, "json");
|
||||||
|
assert.equal(resolved.diagnostics.fallbackReason, "stdout-json-contract-invalid");
|
||||||
|
assert.equal(resolved.diagnostics.stdoutContractAccepted, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("child JSON recovery drops invalid ok-only stdout when artifact fallback is missing", () => {
|
||||||
|
const resolved = resolveCliChildJsonObject({
|
||||||
|
stdout: JSON.stringify({ ok: true }),
|
||||||
|
requestedStdoutType: "web-probe observe analyze compact JSON",
|
||||||
|
acceptParsed: (value) => typeof value.reportJsonPath === "string" || typeof value.counts === "object",
|
||||||
|
artifactFallback: {
|
||||||
|
path: "analysis/report.json",
|
||||||
|
nextCommand: "collect report",
|
||||||
|
read: () => ({ ok: false, reason: "artifact-missing", path: "analysis/report.json" }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolved.parsed, null);
|
||||||
|
assert.equal(resolved.source, null);
|
||||||
|
assert.equal(resolved.diagnostics.stdoutKind, "json");
|
||||||
|
assert.equal(resolved.diagnostics.fallbackReason, "stdout-json-contract-invalid");
|
||||||
|
assert.equal(resolved.diagnostics.stdoutContractAccepted, false);
|
||||||
|
const artifact = resolved.diagnostics.artifact as Record<string, unknown>;
|
||||||
|
assert.equal(artifact.ok, false);
|
||||||
|
assert.equal(artifact.reason, "artifact-missing");
|
||||||
|
assert.equal(artifact.path, "analysis/report.json");
|
||||||
|
assert.equal(artifact.nextCommand, "collect report");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("child JSON recovery reports artifact fallback failure when stdout is unusable and artifact is missing", () => {
|
||||||
|
const resolved = resolveCliChildJsonObject({
|
||||||
|
stdout: "not json",
|
||||||
|
requestedStdoutType: "web-probe observe analyze compact JSON",
|
||||||
|
artifactFallback: {
|
||||||
|
path: "analysis/report.json",
|
||||||
|
nextCommand: "collect report",
|
||||||
|
read: () => ({ ok: false, reason: "artifact-missing", path: "analysis/report.json" }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolved.parsed, null);
|
||||||
|
assert.equal(resolved.source, null);
|
||||||
|
assert.equal(resolved.diagnostics.stdoutKind, "non-json");
|
||||||
|
assert.equal(resolved.diagnostics.fallbackReason, "stdout-not-json");
|
||||||
|
const artifact = resolved.diagnostics.artifact as Record<string, unknown>;
|
||||||
|
assert.equal(artifact.ok, false);
|
||||||
|
assert.equal(artifact.reason, "artifact-missing");
|
||||||
|
assert.equal(artifact.path, "analysis/report.json");
|
||||||
|
assert.equal(artifact.nextCommand, "collect report");
|
||||||
|
});
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
export type CliChildJsonStdoutKind = "json" | "empty" | "dump-wrapper" | "ssh-stdout-truncated" | "ssh-truncation-summary" | "non-json";
|
||||||
|
export type CliChildJsonSource = "stdout" | "dump" | "artifact" | null;
|
||||||
|
|
||||||
|
export interface CliChildJsonArtifactFallback {
|
||||||
|
path: string | null;
|
||||||
|
nextCommand: string | null;
|
||||||
|
read: () => CliChildJsonArtifactReadResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CliChildJsonArtifactReadResult =
|
||||||
|
| { ok: true; value: Record<string, unknown>; path?: string | null; reason?: string | null }
|
||||||
|
| { ok: false; reason: string; path?: string | null; error?: string | null };
|
||||||
|
|
||||||
|
export interface CliChildJsonResolution {
|
||||||
|
parsed: Record<string, unknown> | null;
|
||||||
|
source: CliChildJsonSource;
|
||||||
|
diagnostics: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCliChildJsonObject(options: {
|
||||||
|
stdout: string;
|
||||||
|
stderr?: string;
|
||||||
|
requestedStdoutType: string;
|
||||||
|
acceptParsed?: (value: Record<string, unknown>) => boolean;
|
||||||
|
artifactFallback?: CliChildJsonArtifactFallback | null;
|
||||||
|
forceArtifactFallbackReason?: string | null;
|
||||||
|
}): CliChildJsonResolution {
|
||||||
|
const stderr = options.stderr ?? "";
|
||||||
|
const parsedStdout = parseStdoutCandidate(options.stdout, stderr);
|
||||||
|
const accepted = parsedStdout.parsed !== null && (options.acceptParsed?.(parsedStdout.parsed) ?? true);
|
||||||
|
const contractFallbackReason = parsedStdout.parsed !== null && !accepted ? "stdout-json-contract-invalid" : null;
|
||||||
|
const fallbackReason = options.forceArtifactFallbackReason
|
||||||
|
?? contractFallbackReason
|
||||||
|
?? (parsedStdout.parsed === null ? fallbackReasonForStdout(parsedStdout.stdoutKind) : null);
|
||||||
|
|
||||||
|
let parsed = contractFallbackReason === null ? parsedStdout.parsed : null;
|
||||||
|
let source: CliChildJsonSource = contractFallbackReason === null ? parsedStdout.source : null;
|
||||||
|
let artifactDiagnostics: Record<string, unknown> | null = null;
|
||||||
|
if (fallbackReason !== null && options.artifactFallback) {
|
||||||
|
const artifact = options.artifactFallback.read();
|
||||||
|
artifactDiagnostics = {
|
||||||
|
path: artifact.path ?? options.artifactFallback.path,
|
||||||
|
requestedPath: options.artifactFallback.path,
|
||||||
|
nextCommand: options.artifactFallback.nextCommand,
|
||||||
|
ok: artifact.ok,
|
||||||
|
reason: artifact.ok ? artifact.reason ?? fallbackReason : artifact.reason,
|
||||||
|
error: artifact.ok ? null : artifact.error ?? null,
|
||||||
|
valuesRedacted: true,
|
||||||
|
};
|
||||||
|
if (artifact.ok) {
|
||||||
|
parsed = artifact.value;
|
||||||
|
source = "artifact";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
parsed,
|
||||||
|
source,
|
||||||
|
diagnostics: {
|
||||||
|
requestedStdoutType: options.requestedStdoutType,
|
||||||
|
stdoutKind: parsedStdout.stdoutKind,
|
||||||
|
source,
|
||||||
|
fallbackReason,
|
||||||
|
parsedFromStdout: parsedStdout.source === "stdout",
|
||||||
|
parsedFromDump: parsedStdout.source === "dump",
|
||||||
|
stdoutContractAccepted: accepted,
|
||||||
|
dumpPath: parsedStdout.dumpPath,
|
||||||
|
dumpReason: parsedStdout.dumpReason,
|
||||||
|
dumpReadOk: parsedStdout.dumpReadOk,
|
||||||
|
dumpJsonOk: parsedStdout.dumpJsonOk,
|
||||||
|
sshTruncation: parsedStdout.sshTruncation,
|
||||||
|
artifact: artifactDiagnostics,
|
||||||
|
valuesRedacted: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStdoutCandidate(stdout: string, stderr: string): {
|
||||||
|
parsed: Record<string, unknown> | null;
|
||||||
|
source: CliChildJsonSource;
|
||||||
|
stdoutKind: CliChildJsonStdoutKind;
|
||||||
|
dumpPath: string | null;
|
||||||
|
dumpReason: string | null;
|
||||||
|
dumpReadOk: boolean | null;
|
||||||
|
dumpJsonOk: boolean | null;
|
||||||
|
sshTruncation: Record<string, unknown> | null;
|
||||||
|
} {
|
||||||
|
const trimmed = stdout.trim();
|
||||||
|
const sshTruncation = parseSshTruncation(stdout, stderr);
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
return emptyCandidate("empty", sshTruncation);
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("UNIDESK_SSH_STDOUT_TRUNCATED ")) {
|
||||||
|
const dumpPath = stringValue(parseMarkerJson(trimmed, "UNIDESK_SSH_STDOUT_TRUNCATED "), "dumpPath");
|
||||||
|
return dumpPath ? parseDumpCandidate("ssh-stdout-truncated", dumpPath, "ssh-stdout-truncated", sshTruncation) : emptyCandidate("ssh-stdout-truncated", sshTruncation);
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("UNIDESK_SSH_TRUNCATION_SUMMARY ")) {
|
||||||
|
const dumpPath = stdoutDumpPathFromSshSummary(parseMarkerJson(trimmed, "UNIDESK_SSH_TRUNCATION_SUMMARY "));
|
||||||
|
return dumpPath ? parseDumpCandidate("ssh-truncation-summary", dumpPath, "ssh-truncation-summary", sshTruncation) : emptyCandidate("ssh-truncation-summary", sshTruncation);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseJsonObject(trimmed);
|
||||||
|
if (parsed !== null) {
|
||||||
|
const dumpPayload = cliDumpPayload(parsed);
|
||||||
|
if (dumpPayload !== null) {
|
||||||
|
return parseDumpCandidate("dump-wrapper", dumpPayload.path, dumpPayload.reason, sshTruncation);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
parsed,
|
||||||
|
source: "stdout",
|
||||||
|
stdoutKind: "json",
|
||||||
|
dumpPath: null,
|
||||||
|
dumpReason: null,
|
||||||
|
dumpReadOk: null,
|
||||||
|
dumpJsonOk: null,
|
||||||
|
sshTruncation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryDumpPath = stdoutDumpPathFromSshSummary(sshTruncation);
|
||||||
|
if (summaryDumpPath !== null) {
|
||||||
|
return parseDumpCandidate("ssh-truncation-summary", summaryDumpPath, "ssh-truncation-summary", sshTruncation);
|
||||||
|
}
|
||||||
|
return emptyCandidate("non-json", sshTruncation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDumpCandidate(stdoutKind: CliChildJsonStdoutKind, dumpPath: string, reason: string | null, sshTruncation: Record<string, unknown> | null): {
|
||||||
|
parsed: Record<string, unknown> | null;
|
||||||
|
source: CliChildJsonSource;
|
||||||
|
stdoutKind: CliChildJsonStdoutKind;
|
||||||
|
dumpPath: string | null;
|
||||||
|
dumpReason: string | null;
|
||||||
|
dumpReadOk: boolean | null;
|
||||||
|
dumpJsonOk: boolean | null;
|
||||||
|
sshTruncation: Record<string, unknown> | null;
|
||||||
|
} {
|
||||||
|
if (!existsSync(dumpPath)) {
|
||||||
|
return {
|
||||||
|
...emptyCandidate(stdoutKind, sshTruncation),
|
||||||
|
dumpPath,
|
||||||
|
dumpReason: reason,
|
||||||
|
dumpReadOk: false,
|
||||||
|
dumpJsonOk: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const parsed = parseJsonObject(readFileSync(dumpPath, "utf8"));
|
||||||
|
return {
|
||||||
|
parsed,
|
||||||
|
source: parsed === null ? null : "dump",
|
||||||
|
stdoutKind,
|
||||||
|
dumpPath,
|
||||||
|
dumpReason: reason,
|
||||||
|
dumpReadOk: true,
|
||||||
|
dumpJsonOk: parsed !== null,
|
||||||
|
sshTruncation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyCandidate(stdoutKind: CliChildJsonStdoutKind, sshTruncation: Record<string, unknown> | null) {
|
||||||
|
return {
|
||||||
|
parsed: null,
|
||||||
|
source: null,
|
||||||
|
stdoutKind,
|
||||||
|
dumpPath: null,
|
||||||
|
dumpReason: null,
|
||||||
|
dumpReadOk: null,
|
||||||
|
dumpJsonOk: null,
|
||||||
|
sshTruncation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackReasonForStdout(kind: CliChildJsonStdoutKind): string {
|
||||||
|
if (kind === "empty") return "stdout-empty";
|
||||||
|
if (kind === "dump-wrapper") return "stdout-dump-wrapper-unreadable";
|
||||||
|
if (kind === "ssh-stdout-truncated") return "stdout-ssh-truncated";
|
||||||
|
if (kind === "ssh-truncation-summary") return "stdout-trans-truncated";
|
||||||
|
return "stdout-not-json";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliDumpPayload(parsed: Record<string, unknown>): { path: string; reason: string | null } | null {
|
||||||
|
for (const candidate of [parsed, record(parsed.data), record(parsed.error)]) {
|
||||||
|
if (candidate.outputTruncated !== true) continue;
|
||||||
|
const path = stringValue(record(candidate.dump), "path");
|
||||||
|
if (path !== null) return { path, reason: stringValue(candidate, "reason") };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSshTruncation(stdout: string, stderr: string): Record<string, unknown> | null {
|
||||||
|
const text = `${stdout}\n${stderr}`;
|
||||||
|
const markers = ["UNIDESK_SSH_TRUNCATION_SUMMARY ", "UNIDESK_SSH_STDOUT_TRUNCATED "];
|
||||||
|
for (const marker of markers) {
|
||||||
|
const lines = text.split(/\r?\n/u).filter((line) => line.trim().startsWith(marker));
|
||||||
|
const last = lines.at(-1)?.trim();
|
||||||
|
const parsed = last ? parseMarkerJson(last, marker) : null;
|
||||||
|
if (parsed !== null) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMarkerJson(line: string, marker: string): Record<string, unknown> | null {
|
||||||
|
if (!line.startsWith(marker)) return null;
|
||||||
|
return parseJsonObject(line.slice(marker.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stdoutDumpPathFromSshSummary(value: Record<string, unknown> | null): string | null {
|
||||||
|
const root = record(value);
|
||||||
|
const stdout = record(root.stdout);
|
||||||
|
return stringValue(stdout, "dumpPath") ?? stringValue(root, "dumpPath");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.length === 0) return null;
|
||||||
|
try {
|
||||||
|
return record(JSON.parse(trimmed) as unknown);
|
||||||
|
} catch {
|
||||||
|
const objectText = firstJsonObjectText(trimmed);
|
||||||
|
if (objectText === null) return null;
|
||||||
|
try {
|
||||||
|
return record(JSON.parse(objectText) as unknown);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstJsonObjectText(text: string): string | null {
|
||||||
|
const start = text.indexOf("{");
|
||||||
|
if (start < 0) return null;
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
for (let index = start; index < text.length; index += 1) {
|
||||||
|
const char = text[index];
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) escaped = false;
|
||||||
|
else if (char === "\\") escaped = true;
|
||||||
|
else if (char === "\"") inString = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "\"") {
|
||||||
|
inString = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "{") depth += 1;
|
||||||
|
else if (char === "}") {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) return text.slice(start, index + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: Record<string, unknown>, key: string): string | null {
|
||||||
|
const item = value[key];
|
||||||
|
return typeof item === "string" && item.length > 0 ? item : null;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { repoRoot, rootPath, type Config } from "../config";
|
import { repoRoot, rootPath, type Config } from "../config";
|
||||||
import { runCommand, type CommandResult } from "../command";
|
import { runCommand, type CommandResult } from "../command";
|
||||||
|
import { resolveCliChildJsonObject } from "../cli-child-json-recovery";
|
||||||
import { startJob } from "../jobs";
|
import { startJob } from "../jobs";
|
||||||
import { classifySshTcpPoolFailure } from "../ssh";
|
import { classifySshTcpPoolFailure } from "../ssh";
|
||||||
import { HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, hwlabNodeControlPlaneInfraHelp, runHwlabNodeControlPlaneInfra } from "../hwlab-node-control-plane";
|
import { HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, hwlabNodeControlPlaneInfraHelp, runHwlabNodeControlPlaneInfra } from "../hwlab-node-control-plane";
|
||||||
@@ -2493,20 +2494,48 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption
|
|||||||
"exit \"$analyzer_exit\"",
|
"exit \"$analyzer_exit\"",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
|
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
|
||||||
const primaryAnalysis = recoverWebObserveAnalyzeTurnDetails(options, spec, parseJsonObject(result.stdout));
|
const analysisArtifactDir = options.stateDir ? join(options.stateDir, "analysis") : "";
|
||||||
const artifactAnalysis = (result.timedOut || result.exitCode !== 0 || primaryAnalysis === null)
|
const reportJsonPath = analysisArtifactDir ? join(analysisArtifactDir, "report.json") : null;
|
||||||
? recoverWebObserveAnalyzeFromArtifacts(options, spec, result)
|
const collectReportCommand = webObserveAnalyzeCollectCommand(options, "analysis/report.json");
|
||||||
: null;
|
const stdoutResolution = resolveCliChildJsonObject({
|
||||||
const analysis = recoverWebObserveAnalyzeTurnDetails(options, spec, artifactAnalysis ?? primaryAnalysis);
|
stdout: result.stdout,
|
||||||
|
stderr: result.stderr,
|
||||||
|
requestedStdoutType: "web-probe observe analyze compact JSON",
|
||||||
|
acceptParsed: isWebObserveAnalyzeJsonContract,
|
||||||
|
forceArtifactFallbackReason: result.timedOut ? "remote-command-timeout" : result.exitCode !== 0 ? "remote-command-failed" : null,
|
||||||
|
artifactFallback: {
|
||||||
|
path: reportJsonPath,
|
||||||
|
nextCommand: collectReportCommand,
|
||||||
|
read: () => {
|
||||||
|
const recovered = recoverWebObserveAnalyzeFromArtifacts(options, spec, result);
|
||||||
|
if (recovered !== null) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: recovered,
|
||||||
|
path: stringOrNullValue(recordValue(recovered).reportJsonPath) ?? reportJsonPath,
|
||||||
|
reason: "analysis-artifact-contract",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: false, reason: "analysis-artifact-missing-or-invalid", path: reportJsonPath };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const artifactAnalysis = stdoutResolution.source === "artifact" ? stdoutResolution.parsed : null;
|
||||||
|
const analysis = recoverWebObserveAnalyzeTurnDetails(options, spec, stdoutResolution.parsed);
|
||||||
const analysisOk = analysis?.ok === true;
|
const analysisOk = analysis?.ok === true;
|
||||||
|
const stdoutDiagnostics = stdoutResolution.diagnostics;
|
||||||
|
const failureReason = analysis === null
|
||||||
|
? stringOrNullValue(recordValue(stdoutDiagnostics).fallbackReason) ?? "analyzer-output-not-json"
|
||||||
|
: "analyzer-reported-not-ok";
|
||||||
const analysisFailure = analysisOk
|
const analysisFailure = analysisOk
|
||||||
? null
|
? null
|
||||||
: {
|
: {
|
||||||
reason: analysis === null ? "analyzer-output-not-json" : "analyzer-reported-not-ok",
|
reason: failureReason,
|
||||||
exitCode: result.exitCode,
|
exitCode: result.exitCode,
|
||||||
timedOut: result.timedOut,
|
timedOut: result.timedOut,
|
||||||
parsedJson: analysis !== null,
|
parsedJson: analysis !== null,
|
||||||
recoveredFromArtifacts: artifactAnalysis !== null,
|
recoveredFromArtifacts: artifactAnalysis !== null,
|
||||||
|
stdoutRecovery: stdoutDiagnostics,
|
||||||
stdoutBytes: result.stdout.length,
|
stdoutBytes: result.stdout.length,
|
||||||
stderrBytes: result.stderr.length,
|
stderrBytes: result.stderr.length,
|
||||||
stdoutTail: result.stdout.trim().slice(-1200),
|
stdoutTail: result.stdout.trim().slice(-1200),
|
||||||
@@ -2515,12 +2544,11 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption
|
|||||||
observerId: webObserveIdFromOptions(options),
|
observerId: webObserveIdFromOptions(options),
|
||||||
valuesRedacted: true,
|
valuesRedacted: true,
|
||||||
};
|
};
|
||||||
const analysisArtifactDir = options.stateDir ? join(options.stateDir, "analysis") : "";
|
|
||||||
const failureAnalysis = analysis ?? (analysisFailure ? {
|
const failureAnalysis = analysis ?? (analysisFailure ? {
|
||||||
ok: false,
|
ok: false,
|
||||||
command: "web-probe-observe analyze",
|
command: "web-probe-observe analyze",
|
||||||
stateDir: options.stateDir,
|
stateDir: options.stateDir,
|
||||||
reportJsonPath: analysisArtifactDir ? join(analysisArtifactDir, "report.json") : null,
|
reportJsonPath,
|
||||||
reportMdPath: analysisArtifactDir ? join(analysisArtifactDir, "report.md") : null,
|
reportMdPath: analysisArtifactDir ? join(analysisArtifactDir, "report.md") : null,
|
||||||
analyzer: {
|
analyzer: {
|
||||||
exitCode: result.exitCode,
|
exitCode: result.exitCode,
|
||||||
@@ -2532,6 +2560,7 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption
|
|||||||
stdoutTail: result.stdout.trim().slice(-1200),
|
stdoutTail: result.stdout.trim().slice(-1200),
|
||||||
stderrTail: result.stderr.trim().slice(-1200),
|
stderrTail: result.stderr.trim().slice(-1200),
|
||||||
recoveredFrom: "analyzer-timeout-failure-contract",
|
recoveredFrom: "analyzer-timeout-failure-contract",
|
||||||
|
stdoutRecovery: stdoutDiagnostics,
|
||||||
valuesRedacted: true,
|
valuesRedacted: true,
|
||||||
},
|
},
|
||||||
findings: [{
|
findings: [{
|
||||||
@@ -2543,12 +2572,15 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption
|
|||||||
: "observe analyze failed before producing a compact report; inspect analyzer stdout/stderr artifacts under the observer analysis directory",
|
: "observe analyze failed before producing a compact report; inspect analyzer stdout/stderr artifacts under the observer analysis directory",
|
||||||
}],
|
}],
|
||||||
next: {
|
next: {
|
||||||
collectAnalyzerStdout: options.stateDir ? `bun scripts/cli.ts web-probe observe collect --node ${options.node} --lane ${options.lane} --state-dir ${options.stateDir} --file analysis/analyzer-stdout.json` : null,
|
collectReportJson: collectReportCommand,
|
||||||
collectAnalyzerStderr: options.stateDir ? `bun scripts/cli.ts web-probe observe collect --node ${options.node} --lane ${options.lane} --state-dir ${options.stateDir} --file analysis/analyzer-stderr.log` : null,
|
collectAnalyzerStdout: webObserveAnalyzeCollectCommand(options, "analysis/analyzer-stdout.json"),
|
||||||
|
collectAnalyzerStderr: webObserveAnalyzeCollectCommand(options, "analysis/analyzer-stderr.log"),
|
||||||
valuesRedacted: true,
|
valuesRedacted: true,
|
||||||
},
|
},
|
||||||
valuesRedacted: true,
|
valuesRedacted: true,
|
||||||
} : null);
|
} : null);
|
||||||
|
const compactResult = analysis === null ? compactCommandResultWithStdoutTail(result) : compactCommandResult(result);
|
||||||
|
compactResult.stdoutRecovery = stdoutDiagnostics;
|
||||||
const payload = {
|
const payload = {
|
||||||
ok: analysisOk,
|
ok: analysisOk,
|
||||||
status: analysisOk ? "analyzed" : "blocked",
|
status: analysisOk ? "analyzed" : "blocked",
|
||||||
@@ -2562,13 +2594,39 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption
|
|||||||
alertThresholds,
|
alertThresholds,
|
||||||
browserFreezePolicy,
|
browserFreezePolicy,
|
||||||
wrapper: buildWebObserveWrapperForObserveOptions("analyze", options, spec.workspace),
|
wrapper: buildWebObserveWrapperForObserveOptions("analyze", options, spec.workspace),
|
||||||
result: analysis === null ? compactCommandResultWithStdoutTail(result) : compactCommandResult(result),
|
result: compactResult,
|
||||||
full: options.full,
|
full: options.full,
|
||||||
valuesRedacted: true,
|
valuesRedacted: true,
|
||||||
};
|
};
|
||||||
return options.raw ? compactWebObserveAnalyzePayloadForRaw(payload, options.compactRaw) : withWebObserveAnalyzeRendered(payload);
|
return options.raw ? compactWebObserveAnalyzePayloadForRaw(payload, options.compactRaw) : withWebObserveAnalyzeRendered(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function webObserveAnalyzeCollectCommand(options: NodeWebProbeObserveOptions, file: string): string {
|
||||||
|
const selector = options.stateDir ? `--state-dir ${shellQuote(options.stateDir)}` : webObserveIdFromOptions(options);
|
||||||
|
return `bun scripts/cli.ts web-probe observe collect ${selector} --node ${options.node} --lane ${options.lane} --view files --file ${shellQuote(file)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebObserveAnalyzeJsonContract(value: Record<string, unknown>): boolean {
|
||||||
|
const hasReportPath = typeof value.reportJsonPath === "string" || typeof value.reportMdPath === "string";
|
||||||
|
const hasAnalyzer = Object.keys(recordValue(value.analyzer)).length > 0;
|
||||||
|
const hasFindings = arrayRecordsValue(value.findings).length > 0 || arrayRecordsValue(value.archiveRedFindings).length > 0;
|
||||||
|
const hasAnalyzeData = hasReportPath
|
||||||
|
|| hasAnalyzer
|
||||||
|
|| hasFindings
|
||||||
|
|| Object.keys(recordValue(value.counts)).length > 0
|
||||||
|
|| Object.keys(recordValue(value.sampleMetrics)).length > 0
|
||||||
|
|| Object.keys(recordValue(value.requestRate)).length > 0
|
||||||
|
|| Object.keys(recordValue(value.requestRateCurve)).length > 0
|
||||||
|
|| Object.keys(recordValue(value.frontendPerformance)).length > 0
|
||||||
|
|| Object.keys(recordValue(value.webPerformanceRuntimeDiagnostics)).length > 0
|
||||||
|
|| Object.keys(recordValue(value.runtimeAlerts)).length > 0
|
||||||
|
|| Object.keys(recordValue(value.archiveSummary)).length > 0
|
||||||
|
|| Object.keys(recordValue(value.analysisWindow)).length > 0;
|
||||||
|
const hasFailureData = typeof value.error === "string" || hasAnalyzer || hasFindings || hasReportPath;
|
||||||
|
if (value.ok === false) return hasFailureData;
|
||||||
|
return hasAnalyzeData;
|
||||||
|
}
|
||||||
|
|
||||||
function compactWebObserveAnalyzePayloadForRaw(payload: Record<string, unknown>, compactRaw: boolean): Record<string, unknown> {
|
function compactWebObserveAnalyzePayloadForRaw(payload: Record<string, unknown>, compactRaw: boolean): Record<string, unknown> {
|
||||||
if (!compactRaw) return payload;
|
if (!compactRaw) return payload;
|
||||||
const analysis = payload.analysis && typeof payload.analysis === "object" && !Array.isArray(payload.analysis)
|
const analysis = payload.analysis && typeof payload.analysis === "object" && !Array.isArray(payload.analysis)
|
||||||
|
|||||||
Reference in New Issue
Block a user