* fix(cli): describe one-shot timeouts for an invocation whose session has ended A one-shot CLI analysis that reached a provider deadline told its caller that the Ghidra session remained usable and reported a runtime-editable limit applying to the next operation, although the command had already released its provider session and runtime. The shared error projection now takes an explicit caller lifetime. Providers keep guidance that holds only for a continuing caller (session reuse, same-session runtime updates) apart from the request outcome, and the presentation adds it only for a continuing caller. One-shot owners (runDirectAnalysis, runProviderAnalysis, finishAnalysisCommand and the JavaScript application command) project for the invocation: details.limit reports scope rea-invocation, applies_at next_invocation and the launch environment seed, and the remediation leads with that setting for a new invocation. MCP output keeps its runtime-update and same-session guidance. Covers the Ghidra decompile timeout, decompiler failure, regex stack exhaustion and startup timeout, the shared regex deadline, the Kuna decompile timeout and the Hopper startup timeout. Fixes #8311 Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(cli): project composed child failures for the caller lifetime Cleanup composition embedded its primary and cleanup failures as continuing-caller projections, and baked the primary's message and the cleanup's remediation into the composed error. A one-shot command whose Ghidra startup timed out while shutdown also failed therefore still reported a rea-runtime limit and update_analysis_settings in details.diagnostics.primary_error. childFailureDiagnostics now snapshots each embedded child at composition for both lifetimes when its projection depends on the caller, so cleanup history keeps its composition-time state and the final projection selects the caller's form. composedCallerGuidance re-attributes message and remediation with their continuing-only parts kept separate. Error copies and redaction keep the invocation diagnostics. The Hopper startup timeout now carries its continuing guidance on the error like the other producers. Invocation limits keep agent_editable and rea_restart_required, with values that hold for a new invocation, so only scope and applies_at change and environment is added. In existing-project mode a decompile timeout or a cancelled session now says the next session copies and opens REA_GHIDRA_PROJECT_PATH again rather than repeating import and analysis. Tests compare delivered guidance with the owning formatters and constants instead of freezing prose. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
810 lines
27 KiB
TypeScript
810 lines
27 KiB
TypeScript
import { projectArtifactIntegrityDetails } from "./artifactIntegrity.js";
|
|
import {
|
|
cleanupRetryRecovery,
|
|
cleanupRetryRemediation,
|
|
type AnalysisErrorRecovery,
|
|
} from "./analysisErrorRecovery.js";
|
|
import { jsonValueSchema, type JsonValue } from "./jsonValue.js";
|
|
import { isImmutableJsonSnapshot } from "./immutableJson.js";
|
|
|
|
import {
|
|
analysisErrorCategory,
|
|
analysisErrorRemediationAction,
|
|
analysisErrorUserMessage,
|
|
assertKnownAnalysisErrorTag,
|
|
} from "./analysisErrorPresentation.js";
|
|
import {
|
|
AnalysisAccessDeniedError,
|
|
AnalysisArtifactChangedError,
|
|
AnalysisCancelledError,
|
|
AnalysisCapabilityUnavailableError,
|
|
AnalysisUnsupportedTargetError,
|
|
AnalysisInputError,
|
|
AnalysisOutputError,
|
|
AnalysisTimeoutError,
|
|
AnalysisResourceConstraintError,
|
|
} from "./analysisErrorCore.js";
|
|
import { ArtifactOperationError } from "./artifactOperationError.js";
|
|
import {
|
|
BinaryTargetError,
|
|
ConfigurationError,
|
|
} from "./configurationErrors.js";
|
|
import { BrowserObservationError } from "./browserObservationError.js";
|
|
import {
|
|
AnalysisSnapshotMismatchError,
|
|
EvidenceFileError,
|
|
EvidenceIntegrityError,
|
|
EvidenceReferenceError,
|
|
} from "./evidenceErrors.js";
|
|
import {
|
|
HopperCancelledError,
|
|
HopperProcessError,
|
|
HopperRemoteError,
|
|
HopperStartError,
|
|
HopperTimeoutError,
|
|
} from "./hopperErrors.js";
|
|
import { ProviderAdapterError } from "./providerAdapterError.js";
|
|
import { ProviderSelectionError } from "./providerSelectionError.js";
|
|
import { UnknownRegistryError } from "./unknownRegistryError.js";
|
|
import { providerRetryAction } from "./providerOperationHealth.js";
|
|
import {
|
|
primaryAnalysisError,
|
|
resolvedAnalysisError,
|
|
type AnalysisCallerLifetime,
|
|
type AnalysisError,
|
|
type AnalysisErrorOptions,
|
|
type AnalysisErrorTag,
|
|
} from "./analysisErrorBase.js";
|
|
import { invocationSettingLimit } from "./analysisSettingDefinitions.js";
|
|
|
|
/**
|
|
* Project expected failures into exhaustive, secret-safe caller fields. The
|
|
* default describes a continuing caller, such as an MCP connection, and is
|
|
* also the adapter-independent form retained inside Evidence. An adapter that
|
|
* releases its provider session and runtime after this request, such as a
|
|
* one-shot CLI command, passes `"invocation"`.
|
|
*/
|
|
export const projectAnalysisError = (
|
|
error: AnalysisError,
|
|
callerLifetime: AnalysisCallerLifetime = "continuing",
|
|
): AnalysisErrorProjection => projectError(error, true, callerLifetime);
|
|
|
|
/** Cleanup history references output retained by its enclosing error owner. */
|
|
export const projectAnalysisErrorWithoutCapturedOutput = (
|
|
error: AnalysisError,
|
|
callerLifetime: AnalysisCallerLifetime = "continuing",
|
|
): AnalysisErrorProjection => projectError(error, false, callerLifetime);
|
|
|
|
/** A child failure embedded in a composed error's diagnostics. */
|
|
export interface AnalysisChildFailure {
|
|
readonly error: AnalysisError;
|
|
/** Omit output that the composing error already retains. */
|
|
readonly capturedOutput?: "omit";
|
|
}
|
|
|
|
/**
|
|
* Record child failures as composition-time snapshots, so cleanup history
|
|
* keeps the state it had. The invocation form is recorded only when a child's
|
|
* projection depends on the caller lifetime; the final projection selects it.
|
|
*/
|
|
export const childFailureDiagnostics = (
|
|
children: Readonly<Record<string, AnalysisChildFailure>>,
|
|
diagnostics: Readonly<Record<string, JsonValue>> = {},
|
|
): Pick<AnalysisErrorOptions, "diagnostics" | "invocationDiagnostics"> => {
|
|
const entries = Object.entries(children);
|
|
const project = (
|
|
{ error, capturedOutput }: AnalysisChildFailure,
|
|
callerLifetime: AnalysisCallerLifetime,
|
|
) => projectError(error, capturedOutput !== "omit", callerLifetime);
|
|
const continuing = {
|
|
...diagnostics,
|
|
...Object.fromEntries(
|
|
entries.map(([key, child]) => [key, project(child, "continuing")]),
|
|
),
|
|
};
|
|
const varying = entries.filter(([, { error }]) =>
|
|
projectionDependsOnCallerLifetime(error),
|
|
);
|
|
return varying.length === 0
|
|
? { diagnostics: continuing }
|
|
: {
|
|
diagnostics: continuing,
|
|
invocationDiagnostics: {
|
|
...continuing,
|
|
...Object.fromEntries(
|
|
varying.map(([key, child]) => [key, project(child, "invocation")]),
|
|
),
|
|
},
|
|
};
|
|
};
|
|
|
|
/** Every lifetime-dependent input the projection reads lies on this chain. */
|
|
const projectionDependsOnCallerLifetime = (error: AnalysisError): boolean => {
|
|
for (
|
|
let node: AnalysisError | undefined = error;
|
|
node !== undefined;
|
|
node = node.cleanupPrimary
|
|
)
|
|
if (
|
|
node.limit !== undefined ||
|
|
node.continuingCallerGuidance !== undefined ||
|
|
node.invocationDiagnostics !== undefined
|
|
)
|
|
return true;
|
|
return false;
|
|
};
|
|
|
|
const projectError = (
|
|
error: AnalysisError,
|
|
includeCapturedOutput: boolean,
|
|
callerLifetime: AnalysisCallerLifetime,
|
|
): AnalysisErrorProjection => {
|
|
const composed = error;
|
|
error = resolvedAnalysisError(error);
|
|
let diagnosticSource = error;
|
|
while (
|
|
diagnosticSource.diagnostics === undefined &&
|
|
diagnosticSource.cleanupPrimary !== undefined
|
|
)
|
|
diagnosticSource = diagnosticSource.cleanupPrimary;
|
|
const diagnostics = diagnosticSource.diagnosticsFor(callerLifetime);
|
|
const cleanupHistory =
|
|
composed === error ? undefined : composed.diagnosticsFor(callerLifetime);
|
|
const capturedOutput = composed.capturedOutput ?? error.capturedOutput;
|
|
const partialObservation =
|
|
composed.partialObservation ?? error.partialObservation;
|
|
// A finalizer can add a report above the semantic primary, including while
|
|
// its cleanup is pending or beneath another cleanup composition.
|
|
let cleanupReport = composed.cleanupReport;
|
|
for (
|
|
let owner = composed.cleanupPrimary;
|
|
cleanupReport === undefined && owner !== undefined;
|
|
owner = owner.cleanupPrimary
|
|
)
|
|
cleanupReport = owner.cleanupReport;
|
|
assertKnownAnalysisErrorTag(error._tag);
|
|
const primary = primaryAnalysisError(error);
|
|
assertKnownAnalysisErrorTag(primary._tag);
|
|
const underlyingCode = underlyingErrorCode(primary);
|
|
const code = error.cleanupIncomplete ? "cleanup_incomplete" : underlyingCode;
|
|
const primaryDetails = errorDetails(primary);
|
|
const details = {
|
|
...primaryDetails,
|
|
...(primary.limit === undefined
|
|
? {}
|
|
: {
|
|
limit:
|
|
callerLifetime === "invocation"
|
|
? { ...invocationSettingLimit(primary.limit) }
|
|
: { ...primary.limit },
|
|
}),
|
|
...(cleanupReport === undefined
|
|
? {}
|
|
: { cleanup_report: jsonValueSchema.parse(cleanupReport) }),
|
|
...(diagnostics === undefined
|
|
? {}
|
|
: {
|
|
diagnostics,
|
|
...(primaryDetails?.diagnostics === undefined
|
|
? {}
|
|
: { provider_diagnostics: primaryDetails.diagnostics }),
|
|
}),
|
|
...(cleanupHistory === undefined
|
|
? {}
|
|
: { cleanup_history: cleanupHistory }),
|
|
...(error.cleanupIncomplete
|
|
? {
|
|
cleanup: "incomplete",
|
|
resources: [...error.cleanupResources],
|
|
...(error.cleanup === undefined
|
|
? {}
|
|
: { cleanup_reason: error.cleanup.reason }),
|
|
execution_failure:
|
|
primaryDetails?.execution_failure ?? underlyingCode,
|
|
}
|
|
: {}),
|
|
...(!includeCapturedOutput || capturedOutput === undefined
|
|
? {}
|
|
: {
|
|
captured_output: {
|
|
...capturedOutput,
|
|
},
|
|
}),
|
|
...(partialObservation === undefined
|
|
? {}
|
|
: {
|
|
partial_observation: isImmutableJsonSnapshot(partialObservation)
|
|
? partialObservation
|
|
: jsonValueSchema.parse(partialObservation),
|
|
}),
|
|
};
|
|
// The retained cleanup owner's retry precedes the failed operation's hint.
|
|
const cleanupRetry = error.cleanupRetry;
|
|
const recovery =
|
|
cleanupRetry === undefined
|
|
? errorRecovery(error)
|
|
: cleanupRetryRecovery(cleanupRetry);
|
|
const remediation = analysisErrorRemediationAction(error, callerLifetime);
|
|
return {
|
|
code,
|
|
category: analysisErrorCategory(error),
|
|
message: analysisErrorUserMessage(error, callerLifetime),
|
|
retryable:
|
|
RETRYABLE_CODES.has(code) ||
|
|
(error instanceof AnalysisCapabilityUnavailableError &&
|
|
error.retryable) ||
|
|
(code === "resource_constraint" &&
|
|
error instanceof AnalysisResourceConstraintError &&
|
|
(error.retryable ?? error.resource === "capacity")),
|
|
remediation: {
|
|
action:
|
|
cleanupRetry === undefined
|
|
? remediation
|
|
: `${remediation} ${cleanupRetryRemediation(cleanupRetry)}`,
|
|
},
|
|
...(recovery === undefined ? {} : { recovery }),
|
|
...(Object.keys(details).length === 0 ? {} : { details }),
|
|
};
|
|
};
|
|
|
|
const errorRecovery = (
|
|
error: AnalysisError,
|
|
): AnalysisErrorRecovery | undefined => {
|
|
if (error instanceof AnalysisInputError)
|
|
return error.conformsToInputContract
|
|
? undefined
|
|
: { kind: "inspect_input_contract", operation: error.operation };
|
|
if (error instanceof AnalysisTimeoutError)
|
|
return {
|
|
kind: "inspect_timeout_state",
|
|
operation: error.operation,
|
|
timeout_ms: error.timeoutMs,
|
|
};
|
|
return undefined;
|
|
};
|
|
|
|
const underlyingErrorCode = (
|
|
error: AnalysisError,
|
|
): AnalysisErrorProjection["code"] => {
|
|
if (
|
|
error instanceof HopperRemoteError &&
|
|
error.diagnosticType === "invalid_request"
|
|
)
|
|
return "invalid_request";
|
|
if (error instanceof ProviderSelectionError)
|
|
return error.reason === "provider_unavailable"
|
|
? "provider_unavailable"
|
|
: "capability_unavailable";
|
|
if (error instanceof BrowserObservationError)
|
|
return error.userCategory === "cancelled"
|
|
? "cancelled"
|
|
: BROWSER_ERROR_CODES[error.reason];
|
|
if (error instanceof ArtifactOperationError)
|
|
return artifactOperationCode(error);
|
|
if (error instanceof BinaryTargetError)
|
|
return error.constraint === "load_selection_rejected"
|
|
? "invalid_request"
|
|
: error.systemCode === undefined
|
|
? "target_unavailable"
|
|
: "access_denied";
|
|
if (error instanceof EvidenceFileError) return evidenceFileCode(error.reason);
|
|
if (error instanceof UnknownRegistryError)
|
|
return unknownRegistryCode(error.reason);
|
|
if (error._tag === "ProcessCaptureError") return processCaptureCode(error);
|
|
return staticErrorCode(error._tag);
|
|
};
|
|
|
|
const BROWSER_ERROR_CODES: Readonly<
|
|
Record<BrowserObservationError["reason"], AnalysisErrorProjection["code"]>
|
|
> = {
|
|
endpoint_unreachable: "provider_unavailable",
|
|
invalid_endpoint_response: "unreadable_output",
|
|
target_not_found: "target_unavailable",
|
|
target_not_allowed: "target_unavailable",
|
|
service_worker_blocked: "target_unavailable",
|
|
target_changed: "target_unavailable",
|
|
target_session_detached: "target_unavailable",
|
|
protocol_error: "unreadable_output",
|
|
disconnected: "provider_unavailable",
|
|
payload_limit: "truncated",
|
|
window_not_found: "unreadable_output",
|
|
window_metadata_missing: "unreadable_output",
|
|
process_ownership_unavailable: "unreadable_output",
|
|
cleanup_failed: "execution_failure",
|
|
cancelled: "cancelled",
|
|
timeout: "provider_timeout",
|
|
};
|
|
|
|
const artifactOperationCode = (
|
|
error: ArtifactOperationError,
|
|
): AnalysisErrorProjection["code"] => {
|
|
if (error.reason === "integrity" && error.artifactDetails !== undefined)
|
|
return "artifact_integrity_mismatch";
|
|
if (error.reason === "limit") return "truncated";
|
|
if (error.reason === "cancelled") return "cancelled";
|
|
return "artifact_operation_failed";
|
|
};
|
|
|
|
const evidenceFileCode = (
|
|
reason: EvidenceFileError["reason"],
|
|
): AnalysisErrorProjection["code"] => {
|
|
if (reason === "invalid-json") return "evidence_integrity_mismatch";
|
|
if (reason === "path-limit" || reason === "storage-limit")
|
|
return "resource_constraint";
|
|
// A missing, non-file or already-existing path is the caller's selection.
|
|
if (reason === "io") return "execution_failure";
|
|
return "invalid_request";
|
|
};
|
|
|
|
const unknownRegistryCode = (
|
|
reason: UnknownRegistryError["reason"],
|
|
): AnalysisErrorProjection["code"] => {
|
|
if (reason === "revision-conflict") return "revision_conflict";
|
|
if (reason === "already-exists" || reason === "constraint")
|
|
return "invalid_request";
|
|
if (reason === "limit") return "truncated";
|
|
if (reason === "integrity") return "evidence_integrity_mismatch";
|
|
return "execution_failure";
|
|
};
|
|
|
|
const processCaptureCode = (
|
|
error: AnalysisError,
|
|
): AnalysisErrorProjection["code"] => {
|
|
if (error.cleanupIncomplete) return "cleanup_incomplete";
|
|
if (error.userCategory === "cancelled") return "cancelled";
|
|
return "process_capture_failed";
|
|
};
|
|
|
|
type SpecializedErrorTag =
|
|
| "ArtifactOperationError"
|
|
| "BinaryTargetError"
|
|
| "BrowserObservationError"
|
|
| "EvidenceFileError"
|
|
| "ProcessCaptureError"
|
|
| "ProviderSelectionError"
|
|
| "UnknownRegistryError";
|
|
|
|
const STATIC_ERROR_CODES = {
|
|
AnalysisProtocolError: "unreadable_output",
|
|
AnalysisOutputError: "unreadable_output",
|
|
HopperProtocolError: "unreadable_output",
|
|
AnalysisInputError: "invalid_request",
|
|
AnalysisAccessDeniedError: "access_denied",
|
|
AnalysisArtifactChangedError: "artifact_changed",
|
|
AnalysisCapabilityUnavailableError: "capability_unavailable",
|
|
AnalysisUnsupportedTargetError: "unsupported_target",
|
|
AnalysisCancelledError: "cancelled",
|
|
HopperCancelledError: "cancelled",
|
|
AnalysisTimeoutError: "provider_timeout",
|
|
AnalysisResourceConstraintError: "resource_constraint",
|
|
HopperTimeoutError: "provider_timeout",
|
|
HopperProcessError: "provider_unavailable",
|
|
HopperStartError: "provider_unavailable",
|
|
ConfigurationError: "configuration_invalid",
|
|
NoBinaryOpenError: "target_unavailable",
|
|
EvidenceIntegrityError: "evidence_integrity_mismatch",
|
|
ProviderAdapterError: "execution_failure",
|
|
HopperRemoteError: "execution_failure",
|
|
} as const satisfies Readonly<
|
|
Record<
|
|
Exclude<AnalysisErrorTag, SpecializedErrorTag>,
|
|
AnalysisErrorProjection["code"]
|
|
>
|
|
>;
|
|
|
|
const staticErrorCode = (
|
|
tag: AnalysisErrorTag,
|
|
): AnalysisErrorProjection["code"] => {
|
|
switch (tag) {
|
|
case "ArtifactOperationError":
|
|
case "BinaryTargetError":
|
|
case "BrowserObservationError":
|
|
case "EvidenceFileError":
|
|
case "ProcessCaptureError":
|
|
case "ProviderSelectionError":
|
|
case "UnknownRegistryError":
|
|
throw new TypeError(`Unhandled specialized analysis error: ${tag}`);
|
|
default:
|
|
return STATIC_ERROR_CODES[tag];
|
|
}
|
|
};
|
|
|
|
const errorDetails = (
|
|
error: AnalysisError,
|
|
): Readonly<Record<string, JsonValue>> | undefined =>
|
|
requestErrorDetails(error) ??
|
|
artifactStateErrorDetails(error) ??
|
|
providerErrorDetails(error) ??
|
|
lifecycleErrorDetails(error);
|
|
|
|
const requestErrorDetails = (
|
|
error: AnalysisError,
|
|
): Readonly<Record<string, JsonValue>> | undefined => {
|
|
if (error instanceof AnalysisUnsupportedTargetError)
|
|
return {
|
|
operation: error.operation,
|
|
path: error.path,
|
|
reason: error.reason,
|
|
};
|
|
if (error instanceof AnalysisResourceConstraintError)
|
|
return {
|
|
operation: error.operation,
|
|
resource: error.resource,
|
|
reason: error.reason,
|
|
reported_limits: error.reportedLimits,
|
|
};
|
|
if (error instanceof AnalysisArtifactChangedError)
|
|
return {
|
|
operation: error.operation,
|
|
path: error.path,
|
|
reason: error.reason,
|
|
boundary: "stable-artifact-read",
|
|
};
|
|
if (error instanceof AnalysisAccessDeniedError)
|
|
return {
|
|
operation: error.operation,
|
|
path: error.path,
|
|
system_code: error.systemCode,
|
|
boundary: "filesystem-read",
|
|
};
|
|
if (error instanceof AnalysisOutputError)
|
|
return {
|
|
operation: error.operation,
|
|
reason: error.reason,
|
|
...(error.diagnostics === undefined
|
|
? {}
|
|
: { diagnostics: error.diagnostics }),
|
|
};
|
|
if (error instanceof AnalysisInputError && error.issues.length > 0)
|
|
return {
|
|
operation: error.operation,
|
|
issues: error.issues.map((issue) => ({
|
|
path: [...issue.path],
|
|
reason: issue.reason,
|
|
...(issue.message === undefined ? {} : { message: issue.message }),
|
|
...(issue.expected === undefined ? {} : { expected: issue.expected }),
|
|
...(issue.observed === undefined ? {} : { observed: issue.observed }),
|
|
...(issue.minimum === undefined ? {} : { minimum: issue.minimum }),
|
|
...(issue.maximum === undefined ? {} : { maximum: issue.maximum }),
|
|
})),
|
|
};
|
|
if (error instanceof EvidenceReferenceError)
|
|
return {
|
|
evidence_id: error.evidenceId,
|
|
reason: error.reason,
|
|
...(error.referenceScope === undefined
|
|
? {}
|
|
: { reference_scope: error.referenceScope }),
|
|
expected: error.expected,
|
|
actual: error.actual,
|
|
};
|
|
if (error instanceof AnalysisSnapshotMismatchError)
|
|
return {
|
|
reason: error.message,
|
|
...(error.bindingMismatch === undefined
|
|
? {}
|
|
: { binding_mismatch: jsonValueSchema.parse(error.bindingMismatch) }),
|
|
};
|
|
if (error instanceof EvidenceIntegrityError) return { reason: error.message };
|
|
return undefined;
|
|
};
|
|
|
|
const artifactStateErrorDetails = (
|
|
error: AnalysisError,
|
|
): Readonly<Record<string, JsonValue>> | undefined => {
|
|
if (error instanceof ArtifactOperationError && error.artifactDetails)
|
|
return projectArtifactIntegrityDetails(error.artifactDetails);
|
|
if (error instanceof ArtifactOperationError)
|
|
return {
|
|
operation: error.operation,
|
|
reason: error.reason,
|
|
...(error.reason === "limit" ? { truncated: true } : {}),
|
|
...(error.detail === undefined ? {} : { detail: error.detail }),
|
|
...(error.boundary === undefined ? {} : { boundary: error.boundary }),
|
|
...(error.admission === undefined
|
|
? {}
|
|
: {
|
|
admission: error.admission.kind,
|
|
maximum_header_bytes: error.admission.maximumBytes,
|
|
maximum_header_bytes_source: error.admission.source,
|
|
setting: "REA_ASAR_MAX_HEADER_BYTES",
|
|
}),
|
|
};
|
|
if (error instanceof UnknownRegistryError)
|
|
return {
|
|
reason: error.reason,
|
|
...(error.constraint === undefined
|
|
? {}
|
|
: { constraint: error.constraint }),
|
|
};
|
|
if (error instanceof EvidenceFileError)
|
|
return {
|
|
operation: error.operation,
|
|
reason: error.reason,
|
|
...(error.reason === "path-limit" ? { boundary: "filesystem-path" } : {}),
|
|
...(error.systemCode === undefined
|
|
? {}
|
|
: { system_code: error.systemCode }),
|
|
...(error.path === undefined ? {} : { path: error.path }),
|
|
...(error.contentIssues === undefined
|
|
? {}
|
|
: {
|
|
content_issues: error.contentIssues.map((issue) => ({
|
|
...issue,
|
|
path: [...issue.path],
|
|
})),
|
|
}),
|
|
...(error.published === undefined
|
|
? {}
|
|
: {
|
|
published: true,
|
|
bytes: error.published.bytes,
|
|
...(error.published.path === undefined
|
|
? {}
|
|
: { published_path: error.published.path }),
|
|
}),
|
|
};
|
|
return undefined;
|
|
};
|
|
|
|
const providerErrorDetails = (
|
|
error: AnalysisError,
|
|
): Readonly<Record<string, JsonValue>> | undefined => {
|
|
if (error instanceof AnalysisCapabilityUnavailableError)
|
|
return {
|
|
provider_id: error.providerId,
|
|
operation: error.operation,
|
|
reason: error.reason,
|
|
};
|
|
if (error instanceof ProviderSelectionError)
|
|
return {
|
|
operation: error.operation,
|
|
selection_reason: error.reason,
|
|
requested_provider_id: error.requestedProviderId,
|
|
candidate_ids: [...error.candidateIds],
|
|
rejections: error.rejections.map((rejection) => ({
|
|
provider_id: rejection.providerId,
|
|
origin: rejection.origin,
|
|
code: rejection.code,
|
|
reason: rejection.reason,
|
|
...(rejection.remediation === undefined
|
|
? {}
|
|
: { remediation: rejection.remediation }),
|
|
diagnostics: rejection.diagnostics,
|
|
})),
|
|
};
|
|
if (error instanceof ProviderAdapterError)
|
|
return {
|
|
provider_id: error.providerId,
|
|
operation: error.operation,
|
|
...(error.cleanupIncomplete
|
|
? {
|
|
cleanup: "incomplete",
|
|
resources: [...error.cleanupResources],
|
|
}
|
|
: {}),
|
|
};
|
|
if (error instanceof BrowserObservationError) {
|
|
const primaryReason = primaryBrowserFailureReason(error);
|
|
return {
|
|
operation: error.operation,
|
|
reason: error.reason,
|
|
...(error.cleanupIncomplete
|
|
? {
|
|
cleanup: "incomplete",
|
|
resources: [...error.cleanupResources],
|
|
...(primaryReason === undefined
|
|
? {}
|
|
: { primary_reason: primaryReason }),
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
if (error instanceof HopperRemoteError)
|
|
return {
|
|
stage: "analysis",
|
|
provider_code: error.code,
|
|
diagnostic_type: error.diagnosticType,
|
|
...(error.operation === undefined ? {} : { operation: error.operation }),
|
|
...(error.requestId === undefined ? {} : { request_id: error.requestId }),
|
|
};
|
|
if (error instanceof HopperProcessError) {
|
|
const stage = error.stage;
|
|
return {
|
|
exit_code: error.exitCode,
|
|
stage,
|
|
provider_state: error.providerState,
|
|
retry_action: providerRetryAction(
|
|
error.providerState,
|
|
error.failureCode !== undefined || error.stage === "launch",
|
|
),
|
|
...(error.failureCode === undefined
|
|
? {}
|
|
: { failure_code: error.failureCode }),
|
|
...(error.operation === undefined ? {} : { operation: error.operation }),
|
|
...(error.requestId === undefined ? {} : { request_id: error.requestId }),
|
|
...(error.diagnostic === undefined
|
|
? {}
|
|
: { diagnostics: { ...error.diagnostic } }),
|
|
...(error.launcherFailure === undefined
|
|
? {}
|
|
: { launcher: error.launcherFailure }),
|
|
};
|
|
}
|
|
if (error instanceof HopperStartError)
|
|
return {
|
|
stage: "launch",
|
|
provider_state: "unknown",
|
|
...(error.launchRejection === undefined
|
|
? {}
|
|
: { launch_rejection: { ...error.launchRejection } }),
|
|
retry_action: error.ownerRunId === undefined ? "retry" : "unknown",
|
|
...(error.ownerRunId === undefined
|
|
? {}
|
|
: { owner_run_id: error.ownerRunId }),
|
|
...(error.launcherFailure === undefined
|
|
? {}
|
|
: { launcher: error.launcherFailure }),
|
|
};
|
|
return undefined;
|
|
};
|
|
|
|
const primaryBrowserFailureReason = (
|
|
error: BrowserObservationError,
|
|
): string | undefined => {
|
|
const aggregate = error.cause instanceof AggregateError;
|
|
const primary =
|
|
error.cause instanceof AggregateError ? error.cause.errors[0] : error.cause;
|
|
if (primary instanceof BrowserObservationError) return primary.reason;
|
|
if (primary instanceof AnalysisCancelledError) return "cancelled";
|
|
if (primary instanceof AnalysisTimeoutError) return "timeout";
|
|
if (typeof primary === "object" && primary !== null && "_tag" in primary)
|
|
return typeof primary._tag === "string"
|
|
? primary._tag
|
|
: "execution_failure";
|
|
return aggregate ? "execution_failure" : undefined;
|
|
};
|
|
|
|
const lifecycleErrorDetails = (
|
|
error: AnalysisError,
|
|
): Readonly<Record<string, JsonValue>> | undefined => {
|
|
if (error instanceof AnalysisCancelledError)
|
|
return {
|
|
operation: error.operation,
|
|
cleanup: error.cleanupIncomplete ? "incomplete" : "complete",
|
|
...(error.cleanup === undefined || !error.cleanupIncomplete
|
|
? {}
|
|
: {
|
|
cleanup_reason: error.cleanup.reason,
|
|
resources: [...error.cleanup.resources],
|
|
execution_failure: "cancelled",
|
|
}),
|
|
};
|
|
if (error instanceof HopperCancelledError)
|
|
return { operation: "hopper", cleanup: "complete" };
|
|
if (error instanceof AnalysisTimeoutError)
|
|
return {
|
|
operation: error.operation,
|
|
timeout_ms: error.timeoutMs,
|
|
...(error.cleanup === undefined || !error.cleanupIncomplete
|
|
? {}
|
|
: {
|
|
cleanup: "incomplete",
|
|
cleanup_reason: error.cleanup.reason,
|
|
resources: [...error.cleanup.resources],
|
|
}),
|
|
};
|
|
if (error instanceof HopperTimeoutError)
|
|
return {
|
|
stage: error.operation === undefined ? "startup" : error.stage,
|
|
timeout_ms: error.timeoutMs,
|
|
...(error.launcherOutcome === undefined
|
|
? {}
|
|
: { launcher: error.launcherOutcome }),
|
|
provider_state: error.providerState,
|
|
retry_action: error.providerState === "busy" ? "wait" : "retry",
|
|
...(error.operation === undefined ? {} : { operation: error.operation }),
|
|
...(error.requestId === undefined ? {} : { request_id: error.requestId }),
|
|
};
|
|
if (error._tag === "ProcessCaptureError" && error.cleanupIncomplete)
|
|
return {
|
|
cleanup: "incomplete",
|
|
resources: [...error.cleanupResources],
|
|
...(error.cleanupReport === undefined
|
|
? {}
|
|
: { cleanup_report: jsonValueSchema.parse(error.cleanupReport) }),
|
|
...(error.executionFailure === undefined
|
|
? {}
|
|
: { execution_failure: error.executionFailure }),
|
|
};
|
|
if (error._tag === "ProcessCaptureError") {
|
|
const details = {
|
|
...(error.userCategory === "cancelled"
|
|
? { operation: "process_capture", cleanup: "complete" }
|
|
: {}),
|
|
...(error.cleanupReport === undefined
|
|
? {}
|
|
: { cleanup_report: jsonValueSchema.parse(error.cleanupReport) }),
|
|
...(error.executionFailure === undefined
|
|
? {}
|
|
: { execution_failure: error.executionFailure }),
|
|
};
|
|
return Object.keys(details).length === 0 ? undefined : details;
|
|
}
|
|
if (error instanceof ConfigurationError && error.settings.length > 0)
|
|
return {
|
|
settings: error.settings.map(({ setting, constraint }) => ({
|
|
setting,
|
|
constraint,
|
|
})),
|
|
};
|
|
if (error instanceof BinaryTargetError)
|
|
return {
|
|
path: error.path,
|
|
reason: error.reason,
|
|
...(error.constraint === undefined
|
|
? {}
|
|
: { constraint: error.constraint }),
|
|
...(error.infoPlist === undefined ? {} : { info_plist: error.infoPlist }),
|
|
...(error.probedPaths === undefined
|
|
? {}
|
|
: { probed_paths: [...error.probedPaths] }),
|
|
...(error.systemCode === undefined
|
|
? {}
|
|
: { system_code: error.systemCode, boundary: "filesystem-read" }),
|
|
};
|
|
return undefined;
|
|
};
|
|
|
|
const RETRYABLE_CODES: ReadonlySet<AnalysisErrorProjection["code"]> = new Set([
|
|
"artifact_changed",
|
|
"provider_timeout",
|
|
"cancelled",
|
|
"revision_conflict",
|
|
"provider_unavailable",
|
|
]);
|
|
|
|
export interface AnalysisErrorProjection extends Readonly<
|
|
Record<string, JsonValue>
|
|
> {
|
|
readonly code:
|
|
| "invalid_request"
|
|
| "access_denied"
|
|
| "artifact_changed"
|
|
| "unreadable_output"
|
|
| "capability_unavailable"
|
|
| "unsupported_target"
|
|
| "provider_unavailable"
|
|
| "provider_timeout"
|
|
| "resource_constraint"
|
|
| "cancelled"
|
|
| "artifact_integrity_mismatch"
|
|
| "artifact_operation_failed"
|
|
| "evidence_integrity_mismatch"
|
|
| "truncated"
|
|
| "process_capture_failed"
|
|
| "cleanup_incomplete"
|
|
| "revision_conflict"
|
|
| "configuration_invalid"
|
|
| "target_unavailable"
|
|
| "execution_failure";
|
|
readonly category:
|
|
| "invalid_input"
|
|
| "unsupported_provider"
|
|
| "unsupported_target"
|
|
| "integrity_mismatch"
|
|
| "truncated"
|
|
| "cancelled"
|
|
| "timeout"
|
|
| "resource_constraint"
|
|
| "unavailable"
|
|
| "execution_failure";
|
|
readonly message: string;
|
|
readonly retryable: boolean;
|
|
readonly remediation: Readonly<{
|
|
action: string;
|
|
}>;
|
|
readonly recovery?: AnalysisErrorRecovery;
|
|
readonly details?: Readonly<Record<string, JsonValue>>;
|
|
}
|