Files
rea/src/domain/analysisErrorBase.ts
T
Dery Ferdika 98ec00f406 fix(setup): preserve cleanup ownership and resource reports (#6920)
* fix(setup): report host-owned cleanup resources alongside the primary's

SetupHost.close documents that it reports the resources a setup host still
owns, but its contract returned only a string. runSetup therefore built the
ProviderCleanupError from the primary failure's cleanupResources alone, so a
resource the host failed to release reached the projection only inside a
free-text reason while the error asks the caller to review the reported local
resources.

Return a SetupCloseReport instead. closeSetupResources absorbs the structured
resources of any cause that reports them, through
ArtifactReaderFailure.cleanupObservation, and leaves opaque failures
message-only so no resource name is invented. runSetup then unions the host's
resources with the primary's before building the typed cleanup error.

Two boundary tests read the close result directly: the skill-cleanup case now
asserts the retained backup path in the report reason rather than in a bare
string, and the shared ownership helper takes an opaque close result because it
only ever awaits undefined from either host it is given.

Validation: RED then green in src/application/Setup.recovery.test.ts, where
the new case previously received only the primary resource; 116 tests across
the seven Setup and cleanup-ownership suites pass; tsc --noEmit over the
changed set, its setup callers, and both boundary tests exits 0; oxlint
reports 0 errors; oxfmt --check is clean; verify:module-boundaries passes.
The two failures in src/setupEnvironment.test.ts reproduce identically on
pristine bded9c628 with this change stashed.

* fix(setup): preserve cleanup ownership through host reports

* fix(setup): bind finalization reports to cleanup owners

* fix(setup): retain live resources in returned cleanup views

* fix(setup): preserve completed cleanup and exact installer resources

* fix(setup): retain delegated cleanup across authority changes

* fix(setup): compose callback and source cleanup authorities

* fix(setup): retain producer cleanup failure reasons

* fix(cli): render structured setup cleanup resources

* fix(setup): separate failure evidence from cleanup ownership

* fix(setup): normalize close failures and unify lease binding

* fix(setup): preserve direct lease resource attribution

---------

Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-24 15:08:10 +08:00

319 lines
12 KiB
TypeScript

import type { JsonValue } from "./jsonValue.js";
/** Stable tags exposed by safe analysis-error projections. */
const ANALYSIS_ERROR_TAGS = [
"AnalysisProtocolError",
"AnalysisInputError",
"AnalysisAccessDeniedError",
"AnalysisArtifactChangedError",
"AnalysisOutputError",
"AnalysisCapabilityUnavailableError",
"AnalysisUnsupportedTargetError",
"AnalysisCancelledError",
"AnalysisTimeoutError",
"AnalysisResourceConstraintError",
"ProviderSelectionError",
"ProviderAdapterError",
"BrowserObservationError",
"ArtifactOperationError",
"ProcessCaptureError",
"EvidenceIntegrityError",
"EvidenceFileError",
"UnknownRegistryError",
"HopperTimeoutError",
"HopperCancelledError",
"HopperProtocolError",
"HopperRemoteError",
"HopperProcessError",
"HopperStartError",
"ConfigurationError",
"NoBinaryOpenError",
"BinaryTargetError",
] as const;
/** Stable tag for an expected analysis failure. */
export type AnalysisErrorTag = (typeof ANALYSIS_ERROR_TAGS)[number];
/** Retained command output associated with a typed failure; truncation stays explicit. */
export interface AnalysisCapturedOutput {
readonly stdout: string;
readonly stderr: string;
readonly truncated: boolean;
readonly stdout_bytes?: number;
readonly stderr_bytes?: number;
readonly exit_code?: number | null;
readonly signal?: string | null;
}
/** Cleanup uncertainty attached to the original typed analysis failure. */
export interface AnalysisCleanupObservation {
readonly reason: string;
readonly resources: readonly string[];
}
/** Optional provider-neutral context that must survive typed error projection. */
export interface AnalysisErrorOptions extends ErrorOptions {
/** Typed primary retained when a later lifecycle failure composes this error. */
readonly cleanupPrimary?: AnalysisError | undefined;
/** Producer-owned, caller-safe facts retained through every typed projection. */
readonly diagnostics?: Readonly<Record<string, JsonValue>> | undefined;
readonly capturedOutput?: AnalysisCapturedOutput | undefined;
readonly cleanup?: AnalysisCleanupObservation | undefined;
readonly partialObservation?: AnalysisPartialObservation | undefined;
}
/** Exact cleanup owner that can attribute its currently unresolved resources. */
export interface AnalysisCleanupResourceOwner {
readonly cleanupResources: readonly string[] | undefined;
}
/** Base class for expected analysis, provider, and session failures. */
export abstract class AnalysisError extends Error {
readonly #cleanupPrimary: AnalysisError | undefined;
// Undefined means unbound; a bound empty set records confirmed completion.
#cleanupDependencies: Set<AnalysisError> | undefined;
#cleanupResourceProjection:
| { readonly cleanupResources: readonly string[] }
| undefined;
#cleanupDependents: WeakRef<AnalysisError>[] = [];
readonly diagnostics: Readonly<Record<string, JsonValue>> | undefined;
readonly capturedOutput: AnalysisCapturedOutput | undefined;
readonly cleanup: AnalysisCleanupObservation | undefined;
cleanupIncomplete: boolean;
#ownedCleanupResources: readonly string[];
#cleanupResourceOwner: AnalysisCleanupResourceOwner | undefined;
#cleanupCompletionOwner: AnalysisCleanupResourceOwner | undefined;
#secondaryFailures: AnalysisError[] = [];
constructor(message: string, options?: AnalysisErrorOptions) {
super(message, options);
this.#cleanupPrimary = options?.cleanupPrimary;
this.diagnostics =
options?.diagnostics === undefined
? undefined
: structuredClone(options.diagnostics);
this.capturedOutput =
options?.capturedOutput === undefined
? undefined
: { ...options.capturedOutput };
this.cleanup = options?.cleanup;
this.cleanupIncomplete = options?.cleanup !== undefined;
this.#ownedCleanupResources = [...(options?.cleanup?.resources ?? [])];
this.partialObservation = options?.partialObservation;
}
abstract readonly _tag: AnalysisErrorTag;
readonly userMessage: string | undefined = undefined;
readonly userCategory: "cancelled" | undefined = undefined;
readonly executionFailure: string | undefined = undefined;
partialObservation: AnalysisPartialObservation | undefined;
get cleanupPrimary(): AnalysisError | undefined {
return this.#cleanupPrimary;
}
/** Later typed failures retained without changing the primary error cause. */
get secondaryFailures(): readonly AnalysisError[] {
return [...this.#secondaryFailures];
}
readonly cleanupReport: ProcessCaptureCleanupReport | undefined = undefined;
/** Live dependencies own current resource attribution; cleanup keeps history. */
get cleanupResources(): readonly string[] {
if (this.#cleanupDependencies === undefined)
return (
this.#cleanupResourceOwner?.cleanupResources ??
this.#ownedCleanupResources
);
const pending = [...this.#cleanupDependencies].filter(
(dependency) => dependency.cleanupIncomplete,
);
if (pending.length === 0) return [];
if (this.#cleanupResourceProjection !== undefined)
return this.#cleanupResourceProjection.cleanupResources;
return [
...new Set(pending.flatMap((dependency) => dependency.cleanupResources)),
];
}
/** Whether cleanup follows an explicit owner or dependent errors. */
get hasCleanupOwner(): boolean {
return (
this.#cleanupResourceOwner !== undefined ||
this.#cleanupCompletionOwner !== undefined ||
this.#cleanupDependencies !== undefined
);
}
/** Bind attribution only to the producer that owns the complete cleanup set. */
bindCleanupResourceOwner(owner: AnalysisCleanupResourceOwner): this {
this.#cleanupResourceOwner = owner;
return this;
}
/** Observe exact completion without replacing the producer's resource facts. */
bindCleanupCompletionOwner(owner: AnalysisCleanupResourceOwner): this {
this.#cleanupCompletionOwner = owner;
return this;
}
/** Initialize resource facts supplied by a more specific error producer. */
protected setCleanupResources(resources: readonly string[]): void {
this.#ownedCleanupResources = [...resources];
}
/** Retain diagnostic facts without replacing the original typed error. */
retainDiagnostics(diagnostics: Readonly<Record<string, JsonValue>>): this {
Object.assign(this, {
diagnostics: structuredClone({ ...this.diagnostics, ...diagnostics }),
});
return this;
}
retainSecondaryFailure(
failure: AnalysisError,
diagnostics?: Readonly<Record<string, JsonValue>>,
): this {
// Secondary resources remain owned by their cleanup lifecycle; preserve
// the primary error's live cleanup status and original output.
if (diagnostics !== undefined) this.retainDiagnostics(diagnostics);
this.#secondaryFailures.push(failure);
return this;
}
/** Keep a composed error incomplete until all explicitly owned source errors resolve. */
bindCleanupDependencies(
dependencies: readonly AnalysisError[],
resourceProjection?: { readonly cleanupResources: readonly string[] },
): this {
const pending = (this.#cleanupDependencies ??= new Set<AnalysisError>());
if (resourceProjection !== undefined)
this.#cleanupResourceProjection = resourceProjection;
for (const dependency of dependencies) {
if (!dependency.cleanupIncomplete || pending.has(dependency)) continue;
pending.add(dependency);
dependency.#cleanupDependents = [
...dependency.#cleanupDependents.filter(
(reference) => reference.deref() !== undefined,
),
new WeakRef(this),
];
}
if (pending.size === 0) this.resolveCleanup();
return this;
}
/** Clear live cleanup status only after its owning resource confirms retry success. */
resolveCleanup(): this {
if (
[...(this.#cleanupDependencies ?? [])].some(
(dependency) => dependency.cleanupIncomplete,
)
)
return this;
this.#cleanupDependencies?.clear();
this.cleanupIncomplete = false;
for (const reference of this.#cleanupDependents)
reference.deref()?.resolveCleanup();
this.#cleanupDependents = [];
return this;
}
/** Retain a completed observation when a later step fails. */
retainPartialObservation(observation: AnalysisPartialObservation): this {
this.partialObservation ??= observation;
return this;
}
}
/** Confirmed cleanup reveals the typed primary; unresolved ownership stays visible. */
export const resolvedAnalysisError = (error: AnalysisError): AnalysisError => {
while (!error.cleanupIncomplete && error.cleanupPrimary !== undefined)
error = error.cleanupPrimary;
return error;
};
/** Original operation semantics remain distinct from the live cleanup envelope. */
export const primaryAnalysisError = (error: AnalysisError): AnalysisError => {
while (error.cleanupPrimary !== undefined) error = error.cleanupPrimary;
return error;
};
import type {
PartialProcessCaptureObservation,
ProcessCaptureCleanupReport,
} from "./process/processCapture.js";
import type { FileOffsetPartialObservation } from "./native/fileOffsetPartialObservation.js";
import type { NativeCallPartialObservation } from "./native/nativeCallPartialObservation.js";
import type { ElectronActivePartialObservation } from "./javascript/electronActiveObservation.js";
import type { JavaScriptRuntimeObservation } from "./javascript/javascriptRuntimeObservation.js";
import type {
ArtifactInventoryPartialObservation,
ArtifactExtractionPartialObservation,
} from "./artifactPartialObservation.js";
import type { WebScriptExportPartialObservation } from "./webScriptExport.js";
import type { JavaScriptRecoveryPartialObservation } from "./javascript/javascriptRecovery.js";
import type { BrowserScenarioPartialObservation } from "./browserScenarioCapture.js";
import type { Evidence } from "./evidence.js";
import type { ElectronPagePartialObservation } from "./javascript/electronObservation.js";
import type { AndroidPartialObservation } from "./android/androidPartialObservation.js";
import type { FirmwarePartialObservation } from "./firmware/firmwareAnalysis.js";
import type {
AdbActivityStartPartialObservation,
AdbAcquiredFilePartialObservation,
AdbPackagePullPartialObservation,
AdbDisplayPartialObservation,
} from "./adb/adbDeviceAnalysis.js";
import type { InspectSignature } from "./native/nativeInspection.js";
import type { NativeUiObservationResult } from "./native/nativeUiObservation.js";
import type { BinaryLayout } from "./native/binaryLayout.js";
import type {
RecordedCrash,
RecordedCrashPartialObservation,
} from "./native/recordedCrash.js";
import type { WebPageInspection } from "./browserObservationSchemas.js";
import type { WebBundleAnalysis } from "./webBundleAnalysis.js";
import type { WebExecutionObservation } from "./webExecution.js";
import type { WebEventListenersObservation } from "./webEventListeners.js";
import type { NativeLoadImagePartialObservation } from "./native/nativeLoadImage.js";
import type { NativeAotSnapshotObservation } from "./native/nativeAotSnapshotObservation.js";
import type { FileSnapshotPartialObservation } from "./fileSnapshotObservation.js";
import type { WebModuleResolutionObservation } from "./webModuleTrace.js";
import type { WebNetworkCapture } from "./webNetworkCapture.js";
import type { FridaDeviceList, FridaProcessList } from "./fridaDiscovery.js";
/** Provider-neutral evidence collected before a typed analysis failure. */
export type AnalysisPartialObservation =
| FridaDeviceList
| FridaProcessList
| WebModuleResolutionObservation
| WebNetworkCapture
| NativeLoadImagePartialObservation
| NativeAotSnapshotObservation
| FileSnapshotPartialObservation
| PartialProcessCaptureObservation
| NativeCallPartialObservation
| FileOffsetPartialObservation
| ElectronActivePartialObservation
| BrowserScenarioPartialObservation
| WebPageInspection
| WebBundleAnalysis
| WebExecutionObservation
| WebEventListenersObservation
| JavaScriptRuntimeObservation
| ArtifactInventoryPartialObservation
| ArtifactExtractionPartialObservation
| WebScriptExportPartialObservation
| JavaScriptRecoveryPartialObservation
| Evidence
| BinaryLayout
| RecordedCrash
| RecordedCrashPartialObservation
| AndroidPartialObservation
| FirmwarePartialObservation
| NativeUiObservationResult
| InspectSignature
| ElectronPagePartialObservation
| AdbActivityStartPartialObservation
| AdbAcquiredFilePartialObservation
| AdbPackagePullPartialObservation
| AdbDisplayPartialObservation;