diff --git a/scripts/src/cli-child-json-recovery.test.ts b/scripts/src/cli-child-json-recovery.test.ts new file mode 100644 index 00000000..d3d0bb83 --- /dev/null +++ b/scripts/src/cli-child-json-recovery.test.ts @@ -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; + 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; + 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; + assert.equal(artifact.ok, false); + assert.equal(artifact.reason, "artifact-missing"); + assert.equal(artifact.path, "analysis/report.json"); + assert.equal(artifact.nextCommand, "collect report"); +}); diff --git a/scripts/src/cli-child-json-recovery.ts b/scripts/src/cli-child-json-recovery.ts new file mode 100644 index 00000000..9a750206 --- /dev/null +++ b/scripts/src/cli-child-json-recovery.ts @@ -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; path?: string | null; reason?: string | null } + | { ok: false; reason: string; path?: string | null; error?: string | null }; + +export interface CliChildJsonResolution { + parsed: Record | null; + source: CliChildJsonSource; + diagnostics: Record; +} + +export function resolveCliChildJsonObject(options: { + stdout: string; + stderr?: string; + requestedStdoutType: string; + acceptParsed?: (value: Record) => 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 | 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 | null; + source: CliChildJsonSource; + stdoutKind: CliChildJsonStdoutKind; + dumpPath: string | null; + dumpReason: string | null; + dumpReadOk: boolean | null; + dumpJsonOk: boolean | null; + sshTruncation: Record | 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 | null): { + parsed: Record | null; + source: CliChildJsonSource; + stdoutKind: CliChildJsonStdoutKind; + dumpPath: string | null; + dumpReason: string | null; + dumpReadOk: boolean | null; + dumpJsonOk: boolean | null; + sshTruncation: Record | 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 | 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): { 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 | 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 | null { + if (!line.startsWith(marker)) return null; + return parseJsonObject(line.slice(marker.length)); +} + +function stdoutDumpPathFromSshSummary(value: Record | null): string | null { + const root = record(value); + const stdout = record(root.stdout); + return stringValue(stdout, "dumpPath") ?? stringValue(root, "dumpPath"); +} + +function parseJsonObject(text: string): Record | 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 { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function stringValue(value: Record, key: string): string | null { + const item = value[key]; + return typeof item === "string" && item.length > 0 ? item : null; +} diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts index fd273a0d..ff8c17cb 100644 --- a/scripts/src/hwlab-node/web-probe-observe.ts +++ b/scripts/src/hwlab-node/web-probe-observe.ts @@ -11,6 +11,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from " import { dirname, join } from "node:path"; import { repoRoot, rootPath, type Config } from "../config"; import { runCommand, type CommandResult } from "../command"; +import { resolveCliChildJsonObject } from "../cli-child-json-recovery"; import { startJob } from "../jobs"; import { classifySshTcpPoolFailure } from "../ssh"; 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\"", ].join("\n"); const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds); - const primaryAnalysis = recoverWebObserveAnalyzeTurnDetails(options, spec, parseJsonObject(result.stdout)); - const artifactAnalysis = (result.timedOut || result.exitCode !== 0 || primaryAnalysis === null) - ? recoverWebObserveAnalyzeFromArtifacts(options, spec, result) - : null; - const analysis = recoverWebObserveAnalyzeTurnDetails(options, spec, artifactAnalysis ?? primaryAnalysis); + const analysisArtifactDir = options.stateDir ? join(options.stateDir, "analysis") : ""; + const reportJsonPath = analysisArtifactDir ? join(analysisArtifactDir, "report.json") : null; + const collectReportCommand = webObserveAnalyzeCollectCommand(options, "analysis/report.json"); + const stdoutResolution = resolveCliChildJsonObject({ + 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 stdoutDiagnostics = stdoutResolution.diagnostics; + const failureReason = analysis === null + ? stringOrNullValue(recordValue(stdoutDiagnostics).fallbackReason) ?? "analyzer-output-not-json" + : "analyzer-reported-not-ok"; const analysisFailure = analysisOk ? null : { - reason: analysis === null ? "analyzer-output-not-json" : "analyzer-reported-not-ok", + reason: failureReason, exitCode: result.exitCode, timedOut: result.timedOut, parsedJson: analysis !== null, recoveredFromArtifacts: artifactAnalysis !== null, + stdoutRecovery: stdoutDiagnostics, stdoutBytes: result.stdout.length, stderrBytes: result.stderr.length, stdoutTail: result.stdout.trim().slice(-1200), @@ -2515,12 +2544,11 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption observerId: webObserveIdFromOptions(options), valuesRedacted: true, }; - const analysisArtifactDir = options.stateDir ? join(options.stateDir, "analysis") : ""; const failureAnalysis = analysis ?? (analysisFailure ? { ok: false, command: "web-probe-observe analyze", stateDir: options.stateDir, - reportJsonPath: analysisArtifactDir ? join(analysisArtifactDir, "report.json") : null, + reportJsonPath, reportMdPath: analysisArtifactDir ? join(analysisArtifactDir, "report.md") : null, analyzer: { exitCode: result.exitCode, @@ -2532,6 +2560,7 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption stdoutTail: result.stdout.trim().slice(-1200), stderrTail: result.stderr.trim().slice(-1200), recoveredFrom: "analyzer-timeout-failure-contract", + stdoutRecovery: stdoutDiagnostics, valuesRedacted: true, }, 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", }], 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, - 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, + collectReportJson: collectReportCommand, + collectAnalyzerStdout: webObserveAnalyzeCollectCommand(options, "analysis/analyzer-stdout.json"), + collectAnalyzerStderr: webObserveAnalyzeCollectCommand(options, "analysis/analyzer-stderr.log"), valuesRedacted: true, }, valuesRedacted: true, } : null); + const compactResult = analysis === null ? compactCommandResultWithStdoutTail(result) : compactCommandResult(result); + compactResult.stdoutRecovery = stdoutDiagnostics; const payload = { ok: analysisOk, status: analysisOk ? "analyzed" : "blocked", @@ -2562,13 +2594,39 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption alertThresholds, browserFreezePolicy, wrapper: buildWebObserveWrapperForObserveOptions("analyze", options, spec.workspace), - result: analysis === null ? compactCommandResultWithStdoutTail(result) : compactCommandResult(result), + result: compactResult, full: options.full, valuesRedacted: true, }; 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): 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, compactRaw: boolean): Record { if (!compactRaw) return payload; const analysis = payload.analysis && typeof payload.analysis === "object" && !Array.isArray(payload.analysis)