Files
rea/src/domain/analysisErrorProjection.ts
T

370 lines
13 KiB
TypeScript

import type { JsonValue } from "./jsonValue.js";
import type {
MissingPermissionScope,
PermissionScope,
} from "./permissionPolicy.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisTimeoutError,
ArtifactOperationError,
BinaryTargetError,
BrowserObservationError,
EvidenceFileError,
EvidenceLimitError,
EvidenceReferenceError,
HopperCancelledError,
HopperProcessError,
HopperRemoteError,
HopperTimeoutError,
InvestigationWorkspaceError,
PermissionRequiredError,
ProviderAdapterError,
ProviderSelectionError,
ReplayPlanStaleError,
UnknownRegistryError,
type AnalysisError,
type AnalysisErrorProjection,
type AnalysisErrorTag,
} from "./errors.js";
import {
analysisErrorCategory,
analysisErrorRemediationAction,
analysisErrorUserMessage,
assertKnownAnalysisErrorTag,
} from "./analysisErrorPresentation.js";
/** Project expected failures into exhaustive, secret-safe caller fields. */
export const projectAnalysisError = (
error: AnalysisError,
): AnalysisErrorProjection => {
assertKnownAnalysisErrorTag(error._tag);
const code = errorCode(error);
const details = errorDetails(error);
return {
code,
category: analysisErrorCategory(error),
message: analysisErrorUserMessage(error),
retryable: RETRYABLE_CODES.has(code),
remediation: {
action: analysisErrorRemediationAction(error),
restart_required:
error instanceof PermissionRequiredError && error.restartRequired,
...(error instanceof PermissionRequiredError
? { elicitation_supported: error.elicitationSupported }
: {}),
},
...(details === undefined ? {} : { details }),
};
};
const errorCode = (error: AnalysisError): AnalysisErrorProjection["code"] => {
if (error instanceof ReplayPlanStaleError) return "plan_stale";
if (error instanceof PermissionRequiredError) return "permission_required";
if (error instanceof ProviderSelectionError)
return error.reason === "provider_unavailable"
? "provider_unavailable"
: "capability_unavailable";
if (error instanceof BrowserObservationError)
return browserErrorCode(error.reason);
if (error instanceof ArtifactOperationError)
return artifactOperationCode(error);
if (error instanceof EvidenceFileError) return evidenceFileCode(error.reason);
if (error instanceof InvestigationWorkspaceError)
return investigationWorkspaceCode(error.reason);
if (error instanceof UnknownRegistryError)
return unknownRegistryCode(error.reason);
if (error._tag === "ProcessCaptureError") return processCaptureCode(error);
return staticErrorCode(error._tag);
};
const browserErrorCode = (
reason: BrowserObservationError["reason"],
): AnalysisErrorProjection["code"] => {
if (reason === "payload_limit") return "truncated";
if (
reason === "target_not_found" ||
reason === "target_not_allowed" ||
reason === "target_changed"
)
return "target_unavailable";
if (reason === "endpoint_unreachable" || reason === "disconnected")
return "provider_unavailable";
return "unreadable_output";
};
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 === "outside-root") return "outside_approved_root";
if (reason === "too-large") return "truncated";
if (reason === "disabled") return "capability_unavailable";
return "execution_failure";
};
const investigationWorkspaceCode = (
reason: InvestigationWorkspaceError["reason"],
): AnalysisErrorProjection["code"] => {
if (reason === "revision-conflict" || reason === "name-conflict")
return "revision_conflict";
if (reason === "too-large") return "truncated";
if (reason === "disabled") return "capability_unavailable";
if (reason === "outside-root") return "outside_approved_root";
if (reason === "integrity" || reason === "invalid-json")
return "evidence_integrity_mismatch";
return "execution_failure";
};
const unknownRegistryCode = (
reason: UnknownRegistryError["reason"],
): AnalysisErrorProjection["code"] => {
if (reason === "revision-conflict" || reason === "already-exists")
return "revision_conflict";
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";
if (error.userCategory === "permission_required")
return "permission_required";
return "process_capture_failed";
};
type SpecializedErrorTag =
| "ArtifactOperationError"
| "BrowserObservationError"
| "EvidenceFileError"
| "InvestigationWorkspaceError"
| "PermissionRequiredError"
| "ProcessCaptureError"
| "ProviderSelectionError"
| "ReplayPlanStaleError"
| "UnknownRegistryError";
const STATIC_ERROR_CODES = {
AnalysisProtocolError: "unreadable_output",
AnalysisOutputError: "unreadable_output",
HopperProtocolError: "unreadable_output",
AnalysisInputError: "invalid_request",
AnalysisCapabilityUnavailableError: "capability_unavailable",
AnalysisCancelledError: "cancelled",
HopperCancelledError: "cancelled",
AnalysisTimeoutError: "provider_timeout",
HopperTimeoutError: "provider_timeout",
HopperProcessError: "provider_unavailable",
HopperStartError: "provider_unavailable",
ConfigurationError: "configuration_invalid",
NoBinaryOpenError: "target_unavailable",
BinaryTargetError: "target_unavailable",
EvidenceIntegrityError: "evidence_integrity_mismatch",
EvidenceLimitError: "truncated",
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 "BrowserObservationError":
case "EvidenceFileError":
case "InvestigationWorkspaceError":
case "PermissionRequiredError":
case "ProcessCaptureError":
case "ProviderSelectionError":
case "ReplayPlanStaleError":
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 AnalysisInputError && error.issues.length > 0)
return {
operation: error.operation,
issues: error.issues.map((issue) => ({
path: [...issue.path],
reason: issue.reason,
...(issue.expected === undefined ? {} : { expected: issue.expected }),
...(issue.minimum === undefined ? {} : { minimum: issue.minimum }),
...(issue.maximum === undefined ? {} : { maximum: issue.maximum }),
})),
};
if (error instanceof EvidenceReferenceError)
return {
evidence_id: error.evidenceId,
reason: error.reason,
expected: error.expected,
actual: error.actual,
};
if (error instanceof ReplayPlanStaleError)
return {
approved_plan_digest: error.approvedDigest,
actual_plan_digest: error.actualDigest,
application_code_admitted: false,
};
if (error instanceof PermissionRequiredError)
return {
capability: error.requested.capability,
requested: scopeDetails(error.requested),
missing: missingScopeDetails(error.missing),
ceiling: error.ceiling === null ? null : scopeDetails(error.ceiling),
};
return undefined;
};
const artifactStateErrorDetails = (
error: AnalysisError,
): Readonly<Record<string, JsonValue>> | undefined => {
if (error instanceof ArtifactOperationError && error.artifactDetails)
return {
logical_path: error.artifactDetails.logicalPath,
declared_sha256: error.artifactDetails.declaredSha256,
calculated_sha256: error.artifactDetails.calculatedSha256,
unpacked: error.artifactDetails.unpacked,
};
if (error instanceof ArtifactOperationError)
return {
operation: error.operation,
reason: error.reason,
...(error.reason === "limit" ? { truncated: true } : {}),
};
if (error instanceof EvidenceLimitError)
return { limit: error.limit, maximum: error.maximum, truncated: true };
if (error instanceof InvestigationWorkspaceError)
return { operation: error.operation, reason: error.reason };
if (error instanceof UnknownRegistryError) return { reason: error.reason };
if (error instanceof EvidenceFileError)
return { operation: error.operation, reason: error.reason };
return undefined;
};
const providerErrorDetails = (
error: AnalysisError,
): Readonly<Record<string, JsonValue>> | undefined => {
if (error instanceof AnalysisCapabilityUnavailableError)
return { provider_id: error.providerId, operation: error.operation };
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,
code: rejection.code,
reason: rejection.reason,
diagnostics: rejection.diagnostics,
})),
};
if (error instanceof ProviderAdapterError)
return {
provider_id: error.providerId,
operation: error.operation,
...(error.diagnostics === undefined
? {}
: { diagnostics: error.diagnostics }),
};
if (error instanceof BrowserObservationError)
return { operation: error.operation, reason: error.reason };
if (error instanceof HopperRemoteError)
return { provider_code: error.code, diagnostic_type: error.diagnosticType };
if (error instanceof HopperProcessError && error.failureCode !== undefined)
return { failure_code: error.failureCode, exit_code: error.exitCode };
return undefined;
};
const lifecycleErrorDetails = (
error: AnalysisError,
): Readonly<Record<string, JsonValue>> | undefined => {
if (error instanceof AnalysisCancelledError)
return { operation: error.operation, cleanup: "complete" };
if (error instanceof HopperCancelledError)
return { operation: "hopper", cleanup: "complete" };
if (error instanceof AnalysisTimeoutError)
return { operation: error.operation, timeout_ms: error.timeoutMs };
if (error instanceof HopperTimeoutError)
return { operation: "hopper", timeout_ms: error.timeoutMs };
if (error._tag === "ProcessCaptureError" && error.cleanupIncomplete)
return {
cleanup: "incomplete",
resources: [...error.cleanupResources],
};
if (
error._tag === "ProcessCaptureError" &&
error.userCategory === "cancelled"
)
return { operation: "process_capture", cleanup: "complete" };
if (error instanceof BinaryTargetError) return { path: error.path };
return undefined;
};
const scopeDetails = (
scope: PermissionScope,
): Readonly<Record<string, JsonValue>> => ({
capability: scope.capability,
roots: [...scope.roots],
executables: [...scope.executables],
environment_names: [...scope.environment_names],
...(scope.origins === undefined ? {} : { origins: [...scope.origins] }),
network: scope.network,
mount: scope.mount,
});
const missingScopeDetails = (
scope: MissingPermissionScope,
): Readonly<Record<string, JsonValue>> => ({
...(scope.roots === undefined ? {} : { roots: [...scope.roots] }),
...(scope.executables === undefined
? {}
: { executables: [...scope.executables] }),
...(scope.environment_names === undefined
? {}
: { environment_names: [...scope.environment_names] }),
...(scope.origins === undefined ? {} : { origins: [...scope.origins] }),
...(scope.network === undefined ? {} : { network: scope.network }),
...(scope.mount === undefined ? {} : { mount: scope.mount }),
});
const RETRYABLE_CODES: ReadonlySet<AnalysisErrorProjection["code"]> = new Set([
"invalid_request",
"provider_timeout",
"cancelled",
"revision_conflict",
"provider_unavailable",
]);