Files
rea/src/application/WebScriptExportService.ts
T
morluto a721c42c07 fix(cancellation): bind retained errors to cleanup owners
Carry the capture handle cleanup lease through retry results and bind composed export failures to it. Bind aborted binary retirement errors to the failed close so successful later cleanup reveals the original cancellation.

Validation: 46 focused tests passed; npm run check passed. Four regression assertions fail on the original source. Full suite and real-provider workflows were not run.
2026-10-22 04:17:14 +08:00

418 lines
13 KiB
TypeScript

import { createHash } from "node:crypto";
import { isAbsolute } from "node:path";
import { z } from "zod";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import {
AnalysisCleanupLeaseGroup,
type AnalysisCleanupLease,
} from "../artifacts/AnalysisCleanupLease.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { publishWebScripts } from "../browser/assets/PublishWebScripts.js";
import { selectScriptCapture } from "../browser/assets/ScriptCaptureAdapters.js";
import {
AnalysisAccessDeniedError,
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok, type Result } from "../domain/result.js";
import { safeParseJson } from "../domain/safeJson.js";
import {
exportWebScriptsInputSchema,
type ExportWebScriptsInput,
} from "../domain/webScriptExport.js";
import { WebScriptExportError } from "../domain/webScriptExportError.js";
import type { ExecutionOptions } from "./AnalysisProvider.js";
import { WEB_SCRIPT_EXPORT_PROVIDER as PROVIDER } from "./InvestigationProviders.js";
import {
readRegularFile,
RegularFileCleanupFailure,
retryRegularFileCleanup,
} from "./RegularFileRead.js";
import {
NonRegularFileReadError,
RegularFileChangedError,
} from "../filesystem/RegularFile.js";
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
const OPERATION = "export_web_scripts";
interface CaptureRead {
readonly bytes: Buffer;
readonly cleanup?: {
readonly reason: string;
readonly incomplete: boolean;
readonly cause: unknown;
readonly lease?: AnalysisCleanupLease;
};
}
interface PreparedCapture {
readonly bytes: Buffer;
readonly loaded: ReturnType<typeof selectScriptCapture>;
}
/** Export one local capture through the shared CLI/MCP application workflow. */
export const exportWebScripts = async (
rawInput: unknown,
resources: ArtifactResourceScope,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
const input = exportWebScriptsInputSchema.safeParse(rawInput);
return input.success
? exportWebScriptsValidated(input.data, resources, options)
: err(
analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput),
);
};
/** Publish input already parsed by a named adapter contract. */
export const exportWebScriptsValidated = async (
input: ExportWebScriptsInput,
resources: ArtifactResourceScope,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
if (!isAbsolute(input.capture_path) || !isAbsolute(input.output_directory))
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: [],
reason: "invalid_format",
message:
"capture_path and output_directory must be absolute filesystem paths on this host.",
},
]),
);
try {
options.signal?.throwIfAborted();
const prepared = await prepareCapture(
input.capture_path,
resources,
options.signal,
);
if (!prepared.ok) return prepared;
const { bytes, loaded } = prepared.value;
options.signal?.throwIfAborted();
const sha256 = createHash("sha256").update(bytes).digest("hex");
const result = await publishWebScripts(input, loaded, sha256, {
resources,
...(options.signal === undefined ? {} : { signal: options.signal }),
});
if (options.signal?.aborted === true)
return err(
new AnalysisCancelledError(OPERATION, {
cause: options.signal.reason,
partialObservation: { kind: "web-script-export", result },
}),
);
return ok(
createEvidence(
{ path: input.capture_path, sha256, format: "file" },
PROVIDER,
{
predicateType: "rea.web-script-export",
operation: OPERATION,
parameters: {
capture_path: input.capture_path,
output_directory: input.output_directory,
},
result: jsonValueSchema.parse(result),
rawResult: null,
confidence: "derived",
authority: "historical-reference",
limitations: result.limitations,
locations: [{ kind: "artifact-path", path: result.manifest.path }],
evidenceLinks:
loaded.sourceEvidenceId === null ? [] : [loaded.sourceEvidenceId],
},
),
);
} catch (cause: unknown) {
return err(mapExportFailure(cause, input, options.signal));
}
};
const prepareCapture = async (
path: string,
resources: ArtifactResourceScope,
signal: AbortSignal | undefined,
): Promise<Result<PreparedCapture, AnalysisError>> => {
const read = await resources.run(() => readCapture(path, resources, signal));
if (!read.ok) return read;
const cleanup = read.value.cleanup;
const loaded = parseCapture(
read.value.bytes,
cleanup === undefined || cleanup.incomplete
? undefined
: { cause: cleanup.cause },
);
if (!loaded.ok)
return err(
cleanup?.incomplete === true
? incompleteCaptureCleanup(loaded.error, path, cleanup)
: loaded.error,
);
if (cleanup !== undefined) {
const primary = new WebScriptExportError(
"io",
path,
`Capture handle close failed: ${cleanup.reason}`,
{ cause: cleanup.cause },
);
return err(
cleanup.incomplete
? incompleteCaptureCleanup(primary, path, cleanup)
: primary,
);
}
return ok({ bytes: read.value.bytes, loaded: loaded.value });
};
const mapExportFailure = (
cause: unknown,
input: ExportWebScriptsInput,
signal: AbortSignal | undefined,
): AnalysisError => {
if (cause instanceof WebScriptExportError) return cause;
if (cause instanceof AnalysisCancelledError && cause !== signal?.reason)
return cause;
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined)
return analysisErrorWithCleanupFailure(
new WebScriptExportError(
cause.reason,
input.output_directory,
cause.message,
{ cause },
),
new ProviderCleanupError(
PROVIDER.id,
cause.cleanup.resources,
{ reason: cause.cleanup.reason },
{ cause, operation: OPERATION },
),
OPERATION,
);
if (signal?.aborted === true)
return new AnalysisCancelledError(OPERATION, {
cause:
cause instanceof ArtifactReaderFailure && cause.cause === signal.reason
? signal.reason
: cause,
});
if (cause instanceof ArtifactReaderFailure)
return new WebScriptExportError(
cause.reason,
input.output_directory,
cause.message,
{ cause },
);
if (cause instanceof z.ZodError)
return new AnalysisOutputError(OPERATION, cause.message, { cause });
if (
cause instanceof Error &&
"code" in cause &&
typeof cause.code === "string"
)
return new WebScriptExportError(
"io",
`${input.capture_path} → ${input.output_directory}`,
cause.message,
{ cause },
);
return new ProviderAdapterError(PROVIDER.id, OPERATION, {
cause,
diagnostics: {
capture_path: input.capture_path,
output_directory: input.output_directory,
error_name: cause instanceof Error ? cause.name : "UnknownError",
error_message:
cause instanceof Error
? cause.message
: typeof cause === "string"
? cause
: "Unknown script export failure",
},
});
};
/**
* Read the caller-selected capture. A missing, non-file or unreadable
* selection is a caller or host-permission failure, not an export failure.
*/
const readCapture = async (
path: string,
resources: ArtifactResourceScope,
signal: AbortSignal | undefined,
): Promise<Result<CaptureRead, AnalysisError>> => {
try {
return ok({ bytes: await readRegularFile(path, { signal }) });
} catch (cause: unknown) {
if (signal?.aborted === true && cause === signal.reason)
return err(captureReadFailure(path, cause, signal));
if (cause instanceof RegularFileCleanupFailure) {
const retry = await retryRegularFileCleanup(cause, resources, signal);
const cleanup = {
reason: retry.cleanup?.reason ?? errorMessage(cause.cleanupCause),
incomplete: retry.cleanup !== undefined,
cause: cause.cleanupCause,
...(retry.cleanupLease === undefined
? {}
: { lease: retry.cleanupLease }),
};
if (retry.outcome.kind === "completed") {
const cancellation =
signal?.aborted === true
? { reason: signal.reason }
: regularFileCancellation(cause);
if (cancellation !== undefined) {
const primary = new AnalysisCancelledError(OPERATION, {
cause: cancellation.reason,
});
return err(
retry.cleanup === undefined
? primary
: incompleteCaptureCleanup(primary, path, cleanup),
);
}
return ok({ bytes: retry.outcome.value as Buffer, cleanup });
}
const primary = captureReadFailure(
path,
retry.outcome.cause,
signal,
retry.cleanup === undefined ? { cause: cause.cleanupCause } : undefined,
);
return err(
retry.cleanup === undefined
? primary
: incompleteCaptureCleanup(primary, path, cleanup),
);
}
return err(captureReadFailure(path, cause, signal));
}
};
const captureReadFailure = (
path: string,
cause: unknown,
signal: AbortSignal | undefined,
closeFailure?: { readonly cause: unknown },
): AnalysisError => {
const causeWithClose =
closeFailure === undefined
? cause
: new AggregateError(
[cause, closeFailure.cause],
"Capture read and file-handle close both failed",
{ cause },
);
if (signal?.aborted === true)
return new AnalysisCancelledError(OPERATION, { cause: causeWithClose });
const code =
cause instanceof Error && "code" in cause ? String(cause.code) : "";
if (code === "EACCES" || code === "EPERM")
return new AnalysisAccessDeniedError(OPERATION, path, code, {
cause: causeWithClose,
});
if (
cause instanceof NonRegularFileReadError ||
cause instanceof RegularFileChangedError ||
code === "ENOENT" ||
code === "ENOTDIR" ||
code === "EISDIR" ||
code === "ENXIO"
)
return new AnalysisInputError(OPERATION, { cause: causeWithClose }, [
{
path: ["capture_path"],
reason: "invalid_value",
message:
cause instanceof NonRegularFileReadError && code !== "EISDIR"
? cause.message
: code === "EISDIR"
? `Selected capture is a directory, not a file: ${path}`
: `Selected capture could not be read (${code}): ${path}`,
},
]);
if (code !== "")
return new WebScriptExportError("io", path, errorMessage(cause), {
cause: causeWithClose,
});
return new ProviderAdapterError(PROVIDER.id, OPERATION, {
cause: causeWithClose,
diagnostics: {
capture_path: path,
error_name: cause instanceof Error ? cause.name : "UnknownError",
error_message: cause instanceof Error ? cause.message : String(cause),
},
});
};
const incompleteCaptureCleanup = (
primary: AnalysisError,
path: string,
cleanup: NonNullable<CaptureRead["cleanup"]>,
): AnalysisError => {
const error = analysisErrorWithCleanupFailure(
primary,
new ProviderCleanupError(
PROVIDER.id,
[path],
{ reason: cleanup.reason },
{ cause: cleanup.cause, operation: OPERATION },
),
OPERATION,
);
if (cleanup.lease !== undefined)
new AnalysisCleanupLeaseGroup(error, [cleanup.lease]);
return error;
};
const errorMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
const parseCapture = (
bytes: Buffer,
closeFailure?: { readonly cause: unknown },
): Result<ReturnType<typeof selectScriptCapture>, AnalysisError> => {
try {
const text = new TextDecoder("utf-8", {
fatal: true,
ignoreBOM: true,
}).decode(bytes);
const json = safeParseJson(text);
if (!json.ok) throw new TypeError(json.error);
return ok(selectScriptCapture(json.value));
} catch (cause: unknown) {
const causeWithClose =
closeFailure === undefined
? cause
: new AggregateError(
[cause, closeFailure.cause],
"Capture parse and file-handle close both failed",
{ cause },
);
return err(
new AnalysisInputError(OPERATION, { cause: causeWithClose }, [
{
path: ["capture_path"],
reason: "invalid_format",
message:
cause instanceof Error
? cause.message
: "Capture is not valid UTF-8 JSON evidence.",
},
]),
);
}
};