Files
rea/src/kuna/KunaProjectCache.ts
T
Kaoru0822andClaude Opus 5.5 8ed3d1df72 fix(kuna): list a cold target from the export's own inventory (#4062)
* fix(kuna): list a cold target from the export's own inventory

On a cold cache the session ran `kuna functions --json` for listings while
the background export ran the same inventory as its first step, so every
cold target was inventoried twice at once. On a 254 MB Mach-O each run took
about 66 s of CPU. The quick run was also bound by the single-function
deadline.

The export's status now carries its parsed inventory, the cache replays the
latest status to requests that join a running build, and listings wait for
that inventory. The session runs its own inventory only when the export ends
without one.

Refs #1957

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kuna): replay a running build's status after its waiter can cancel

The cache replayed the latest status to a joining request before it
registered that request's abort listener, so a listener that cancelled on
the replayed status was ignored and the request waited for the export to
finish. Replay now happens once cancellation is registered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(kuna): publish the export's inventory without parsing it twice

The export parsed `functions.json` for its status and then parsed the file
it had just written again to publish, so two expanded inventories coexisted
while the export finished. Publishing now reuses the inventory parsed from
that same text. Cached lookups still parse the published file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(kuna): exercise export inventory sharing from pseudocode requests

Inventory-only requests no longer start the export, so a cold request that
shares the export's inventory is now a text request. The tests and the
guide describe that trigger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kuna): let a listing wait for an export that is still starting

A listing that arrived while a concurrent text request was still looking up
the cache saw no export inventory yet and started its own `kuna functions`,
and the export then started another. The listing now waits for that startup
decision before falling back to its own run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 05:19:25 -07:00

786 lines
26 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import {
mkdir,
mkdtemp,
readdir,
readFile,
rename,
rm,
stat,
utimes,
} from "node:fs/promises";
import { join } from "node:path";
import canonicalize from "canonicalize";
import writeFileAtomic from "write-file-atomic";
import { z } from "zod";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok, type Result } from "../domain/result.js";
import { hashStableFile } from "../filesystem/StableFileHash.js";
import type { KunaInstallation } from "./KunaInstallation.js";
import {
exportKunaProject,
KUNA_EXPORT_OPERATION,
kunaExportFailure,
readKunaExport,
type KunaExport,
type KunaExportStatus,
} from "./KunaProjectExport.js";
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
import type { KunaProcessLauncher } from "./KunaCommand.js";
import type {
KunaInventoryFunction,
KunaProjectFunction,
} from "./KunaProjectFiles.js";
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
/** Bumped whenever the entry layout or manifest meaning changes. */
const CACHE_LAYOUT = 1;
/** A manifest names five files and their digests; anything larger is not ours. */
const MANIFEST_MAX_BYTES = 64 * 1024;
const KEY_PATTERN = /^[0-9a-f]{64}$/u;
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
/** Inputs that determine an export's content. Each one is part of the cache key. */
export interface KunaProjectKeyInputs {
readonly targetSha256: string;
readonly kunaSha256: string;
readonly kunaVersion: string;
/**
* `--jobs 1` is Kuna's reference answer; more jobs decompile without
* callee-first prototypes and change some bodies.
*/
readonly jobs: number;
}
/** Content address of one export: every input that can change its bytes. */
export const kunaProjectKey = (inputs: KunaProjectKeyInputs): string =>
createHash("sha256")
.update(
canonicalize({
layout: CACHE_LAYOUT,
target_sha256: inputs.targetSha256,
kuna_sha256: inputs.kunaSha256,
kuna_version: inputs.kunaVersion,
export: { command: "decompile-project", jobs: inputs.jobs },
}) ?? "",
)
.digest("hex");
const fileSchema = z.strictObject({
path: z.string().min(1),
bytes: z.number().int().nonnegative(),
sha256: z.string().regex(SHA256_PATTERN),
});
const manifestSchema = z.strictObject({
layout: z.literal(CACHE_LAYOUT),
key: z.string().regex(KEY_PATTERN),
inputs: z.strictObject({
target_sha256: z.string().regex(SHA256_PATTERN),
kuna_sha256: z.string().regex(SHA256_PATTERN),
kuna_version: z.string().min(1),
jobs: z.number().int().positive(),
}),
files: z.strictObject({
c: fileSchema,
h: fileSchema,
asm: fileSchema,
readme: fileSchema,
inventory: fileSchema,
}),
summary: z.strictObject({
decompiled: z.number().int().nonnegative(),
failed: z.number().int().nonnegative(),
}),
created_at: z.string().datetime(),
});
/** Immutable description of one published export, written once at publish. */
export type KunaProjectManifest = z.infer<typeof manifestSchema>;
/** One verified, published export. */
export interface KunaProject {
readonly key: string;
/** Directory holding the export's `.c`, `.h`, `.asm`, README and inventory. */
readonly directory: string;
readonly manifest: KunaProjectManifest;
/** Decompiled function blocks in `.c` order. */
readonly functions: readonly KunaProjectFunction[];
/** Every callable entry Kuna found, including imports it does not decompile. */
readonly inventory: readonly KunaInventoryFunction[];
}
/** One request to produce or reuse the export for a target. */
export interface KunaProjectRequest {
readonly installation: KunaInstallation;
/** Caller-selected target path; REA decompiles a verified copy of it. */
readonly targetPath: string;
readonly targetSha256: string;
readonly signal?: AbortSignal;
readonly onStatus?: (status: KunaExportStatus) => void;
}
/** One build whose process tree REA could not prove stopped. */
interface RetainedProcess {
readonly key: string;
readonly error: ProviderCleanupError;
/** Retries stopping the process tree, when the launcher left one. */
readonly owner: ProviderProcessSupervisor | undefined;
readonly staging: string;
}
interface StagedResult {
readonly result: Result<KunaProject, AnalysisError>;
/** Set when an owned process may still write into staging. */
readonly retainStaging: boolean;
}
interface InflightBuild {
readonly key: string;
readonly promise: Promise<Result<KunaProject, AnalysisError>>;
readonly controller: AbortController;
readonly listeners: Set<(status: KunaExportStatus) => void>;
/** The latest status, replayed to requests that join after it was reported. */
status: KunaExportStatus | undefined;
waiters: number;
}
const isMissing = (cause: unknown): boolean =>
cause instanceof Error &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "ENOTDIR");
/** A file an entry's manifest names is gone, replaced or changed under it. */
const isDamaged = (cause: unknown): boolean =>
cause instanceof Error &&
"code" in cause &&
["ENOENT", "ENOTDIR", "EISDIR", "ENOTFILE", "ESTALE"].includes(
String(cause.code),
);
const isPublishedByAnother = (cause: unknown): boolean =>
cause instanceof Error &&
"code" in cause &&
(cause.code === "EEXIST" || cause.code === "ENOTEMPTY");
const parseManifest = (text: string): KunaProjectManifest | undefined => {
try {
const parsed = manifestSchema.safeParse(JSON.parse(text));
return parsed.success ? parsed.data : undefined;
} catch {
return undefined;
}
};
const manifestBytes = (manifest: KunaProjectManifest): number =>
Object.values(manifest.files).reduce((sum, file) => sum + file.bytes, 0);
/**
* REA-owned on-disk cache of `kuna decompile-project` exports.
*
* Layout under the root: `projects/<key>/` holds published entries
* (`manifest.json` plus the export and inventory in `project/`), `staging/`
* holds builds whose processes may still be live, `trash/` holds retired
* workspaces and evicted entries awaiting removal,
* and `home/` is Kuna's own HOME so its spec cache stays inside REA's
* ownership.
*
* Entries are content-addressed and immutable. A build runs in private
* staging and is published by one directory rename, so a reader sees a
* complete entry or none, and concurrent publishers of one key keep the first.
* File digests are recorded once at publish and checked on every first read.
* Recency is the entry directory's mtime; publishing evicts the least recently
* used entries above the size cap, never an entry pinned by this process.
*/
export class KunaProjectCache {
readonly #root: string;
readonly #maxBytes: number;
readonly #launcher: KunaProcessLauncher | undefined;
/** Builds new requests may join, at most one per key. */
readonly #inflight = new Map<string, InflightBuild>();
/** Every build not yet settled, including abandoned ones a retry replaced. */
readonly #unfinished = new Set<InflightBuild>();
readonly #verified = new Map<string, KunaProject>();
readonly #pins = new Map<string, number>();
/** One eviction decision at a time; exports themselves remain concurrent. */
#eviction: Promise<void> = Promise.resolve();
#capacity: "within_limit" | "pinned" | "building" = "within_limit";
#maintenance: Promise<void> | undefined;
#maintenanceRequested = false;
/** Builds whose process tree could not be proven stopped; their staging stays. */
readonly #retainedProcesses: RetainedProcess[] = [];
/** Only resources whose process cleanup is proven may be retried as files. */
readonly #retainedFiles = new Map<
string,
{ readonly key: string | null; readonly error: ProviderCleanupError }
>();
constructor(
root: string,
options: {
readonly maxBytes: number;
readonly launcher?: KunaProcessLauncher;
},
) {
this.#root = root;
this.#maxBytes = options.maxBytes;
this.#launcher = options.launcher;
}
/** Keep an entry from eviction while this process serves it. */
pin(key: string): () => void {
this.#pins.set(key, (this.#pins.get(key) ?? 0) + 1);
let released = false;
return () => {
if (released) return;
released = true;
const count = (this.#pins.get(key) ?? 1) - 1;
if (count === 0) {
this.#pins.delete(key);
if (this.#capacity !== "within_limit") this.#scheduleEviction();
} else this.#pins.set(key, count);
};
}
/**
* Return the verified published entry for a key, or undefined when none is
* usable. A corrupt or tampered entry is removed and reported as absent.
*/
async lookup(
key: string,
signal?: AbortSignal,
): Promise<KunaProject | undefined> {
const verified = this.#verified.get(key);
if (verified !== undefined) {
// Another process may have evicted it since this one verified it.
if (await this.#touch(key)) return verified;
this.#verified.delete(key);
return undefined;
}
const entry = join(this.#root, "projects", key);
let text: string;
try {
text = await this.#readBounded(join(entry, "manifest.json"));
} catch (cause: unknown) {
if (isMissing(cause)) return undefined;
if (!(cause instanceof RangeError)) throw cause;
await this.#discard(key);
return undefined;
}
const manifest = parseManifest(text);
if (manifest?.key !== key) {
await this.#discard(key);
return undefined;
}
const directory = join(entry, "project");
for (const file of Object.values(manifest.files)) {
let observed: { readonly sha256: string; readonly bytes: number };
try {
observed = await hashStableFile(join(directory, file.path), signal);
} catch (cause: unknown) {
signal?.throwIfAborted();
if (!isDamaged(cause)) throw cause;
await this.#discard(key);
return undefined;
}
if (observed.sha256 !== file.sha256 || observed.bytes !== file.bytes) {
await this.#discard(key);
return undefined;
}
}
const parsed = await readKunaExport(
directory,
manifest.files,
manifest.summary,
signal,
);
if (!parsed.ok) {
await this.#discard(key);
return undefined;
}
const project = { key, directory, manifest, ...parsed.value };
if (!(await this.#touch(key))) return undefined;
this.#verified.set(key, project);
return project;
}
/**
* Return the entry for a request, building and publishing it when absent.
* Concurrent requests for one key share one Kuna run; the run is cancelled
* only when every waiting request has been cancelled.
*/
async build(
request: KunaProjectRequest,
): Promise<Result<KunaProject, AnalysisError>> {
if (request.signal?.aborted === true)
return err(new AnalysisCancelledError(KUNA_EXPORT_OPERATION));
const key = kunaProjectKey({
targetSha256: request.targetSha256,
kunaSha256: request.installation.sha256,
kunaVersion: request.installation.version,
jobs: request.installation.configuration.jobs,
});
const running = this.#inflight.get(key);
// A build whose waiters all left is winding down; a new request starts afresh.
if (running !== undefined && !running.controller.signal.aborted)
return this.#await(running, request);
const controller = new AbortController();
const listeners = new Set<(status: KunaExportStatus) => void>();
// #produce reports its first status only after an await, once `created` exists.
const promise = this.#produce(key, request, controller.signal, (status) => {
created.status = status;
for (const listener of listeners) listener(status);
});
const created: InflightBuild = {
key,
promise,
controller,
listeners,
status: undefined,
waiters: 0,
};
this.#inflight.set(key, created);
this.#unfinished.add(created);
void promise.then(() => {
this.#unfinished.delete(created);
if (this.#inflight.get(key) === created) this.#inflight.delete(key);
if (this.#unfinished.size === 0 && this.#capacity === "building")
this.#scheduleEviction();
});
return this.#await(created, request);
}
/**
* Wait until a build that every waiter abandoned has stopped its Kuna
* process and removed its staging. A build another request still waits for
* keeps running and is not awaited.
*/
async settled(key: string): Promise<Result<null, ProviderCleanupError>> {
await Promise.allSettled(
[...this.#unfinished]
.filter((entry) => entry.key === key && entry.controller.signal.aborted)
.map((entry) => entry.promise),
);
await this.#retryProcesses((retained) => retained.key === key);
await this.#retryFiles(key);
return cleanupResult(
[...this.#retainedProcesses, ...this.#retainedFiles.values()]
.filter((retained) => retained.key === key)
.map(({ error }) => error),
);
}
/** Wait for builds, retry safe file removal, and report unresolved cleanup. */
async close(): Promise<Result<null, ProviderCleanupError>> {
await Promise.allSettled(
[...this.#unfinished].map((entry) => entry.promise),
);
while (this.#maintenance !== undefined) await this.#maintenance;
await this.#retryProcesses(() => true);
await this.#retryFiles();
return cleanupResult([
...this.#retainedProcesses.map(({ error }) => error),
...[...this.#retainedFiles.values()].map(({ error }) => error),
]);
}
/** Retry stopping retained process trees; retire the staging of each one proven stopped. */
async #retryProcesses(
selected: (retained: RetainedProcess) => boolean,
): Promise<void> {
for (const retained of this.#retainedProcesses.filter(selected)) {
const stopped = await retained.owner?.stop();
if (stopped === undefined || stopped.status === "incomplete") continue;
this.#retainedProcesses.splice(
this.#retainedProcesses.indexOf(retained),
1,
);
await this.#retireStaging(retained.staging, retained.key);
}
}
/** Retry only stopped file resources belonging to the closing task, or all. */
async #retryFiles(key?: string): Promise<void> {
for (const [path, retained] of [...this.#retainedFiles])
if (key === undefined || retained.key === key)
await this.#removeRetired(path).catch(() => undefined);
}
/**
* Move proven-stopped staging into trash and remove it. A failed removal
* stays owned as a file cleanup that close retries.
*/
async #retireStaging(
staging: string,
key: string,
): Promise<ProviderCleanupError | undefined> {
let cleanupPath = staging;
try {
const retired = join(this.#root, "trash", `build-${randomUUID()}`);
await rename(staging, retired);
cleanupPath = retired;
await this.#removeRetired(retired, key);
return undefined;
} catch (cause: unknown) {
if (cause instanceof ProviderCleanupError) return cause;
return this.#retainFileCleanup(cleanupPath, cause, key);
}
}
#await(
entry: InflightBuild,
request: KunaProjectRequest,
): Promise<Result<KunaProject, AnalysisError>> {
entry.waiters += 1;
const onStatus = request.onStatus;
// Each waiter owns its registration, even when callers reuse a callback.
const listener =
onStatus === undefined
? undefined
: (status: KunaExportStatus) => onStatus(status);
if (listener !== undefined) entry.listeners.add(listener);
return new Promise((resolve) => {
let settled = false;
const leave = () => {
if (listener !== undefined) entry.listeners.delete(listener);
entry.waiters -= 1;
};
const onAbort = () => {
if (settled) return;
settled = true;
leave();
if (entry.waiters === 0) entry.controller.abort();
resolve(err(new AnalysisCancelledError(KUNA_EXPORT_OPERATION)));
};
request.signal?.addEventListener("abort", onAbort, { once: true });
void entry.promise.then((result) => {
request.signal?.removeEventListener("abort", onAbort);
if (settled) return;
settled = true;
leave();
resolve(result);
});
// Replay only once cancellation is registered, so a listener may abort.
if (listener !== undefined && entry.status !== undefined)
listener(entry.status);
});
}
async #produce(
key: string,
request: KunaProjectRequest,
signal: AbortSignal,
onStatus: (status: KunaExportStatus) => void,
): Promise<Result<KunaProject, AnalysisError>> {
let staging: string;
try {
const existing = await this.lookup(key, signal);
if (existing !== undefined) return ok(existing);
await Promise.all(
["projects", "staging", "trash", "home"].map((name) =>
mkdir(join(this.#root, name), { recursive: true, mode: 0o700 }),
),
);
await this.#sweep(signal);
staging = await mkdtemp(join(this.#root, "staging", "build-"));
} catch (cause: unknown) {
return err(kunaExportFailure(cause, signal));
}
const built = await this.#exportAndPublish(
key,
staging,
request,
signal,
onStatus,
).catch((cause: unknown): StagedResult => ({
result: err(kunaExportFailure(cause, signal)),
retainStaging: false,
}));
if (built.retainStaging) return built.result;
// Only a proven-stopped build may enter trash. A failed write, crash or
// lost cleanup identity therefore never makes live staging reclaimable.
const retired = await this.#retireStaging(staging, key);
if (retired !== undefined) return err(retired);
if (built.result.ok)
// The entry is published either way; a failed eviction retries on the next publish.
await this.#evict(key).catch(() => undefined);
return built.result;
}
async #exportAndPublish(
key: string,
staging: string,
request: KunaProjectRequest,
signal: AbortSignal,
onStatus: (status: KunaExportStatus) => void,
): Promise<StagedResult> {
const outcome = await exportKunaProject({
installation: request.installation,
targetPath: request.targetPath,
targetSha256: request.targetSha256,
workspace: staging,
output: join(staging, "entry", "project"),
signal,
onStatus,
launcher: this.#launcher,
});
if (outcome.kind === "retained") {
this.#retainedProcesses.push({
key,
error: outcome.error,
owner: outcome.owner,
staging,
});
return { result: err(outcome.error), retainStaging: true };
}
return {
result: outcome.result.ok
? await this.#publish(
key,
staging,
request,
outcome.result.value,
signal,
)
: outcome.result,
retainStaging: false,
};
}
/** Record the manifest and publish the staged export under its key. */
async #publish(
key: string,
staging: string,
request: KunaProjectRequest,
exported: KunaExport,
signal: AbortSignal,
): Promise<Result<KunaProject, AnalysisError>> {
const manifest: KunaProjectManifest = {
layout: CACHE_LAYOUT,
key,
inputs: {
target_sha256: request.targetSha256,
kuna_sha256: request.installation.sha256,
kuna_version: request.installation.version,
jobs: request.installation.configuration.jobs,
},
files: exported.files,
summary: exported.summary,
created_at: new Date().toISOString(),
};
const total = manifestBytes(manifest);
if (total > this.#maxBytes)
return err(
new AnalysisResourceConstraintError(
KUNA_EXPORT_OPERATION,
"capacity",
`This export occupies ${String(total)} bytes, more than the whole Kuna cache may hold.`,
{ cache_max_bytes: this.#maxBytes, export_bytes: total },
{
remediationAction:
"Raise REA_KUNA_CACHE_MAX_BYTES above the export size, or analyze this target with another provider.",
},
),
);
await writeFileAtomic(
join(staging, "entry", "manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
{ mode: 0o600 },
);
const destination = join(this.#root, "projects", key);
try {
await rename(join(staging, "entry"), destination);
} catch (cause: unknown) {
if (!isPublishedByAnother(cause)) throw cause;
const winner = await this.lookup(key, signal);
if (winner !== undefined) return ok(winner);
throw cause;
}
const project = {
key,
directory: join(destination, "project"),
manifest,
functions: exported.functions,
inventory: exported.inventory,
};
this.#verified.set(key, project);
return ok(project);
}
/**
* Remove retired resources only. Age and a missing marker cannot establish
* that a process stopped, so unresolved staging is never swept.
*/
async #sweep(signal: AbortSignal): Promise<void> {
for (const name of await readdir(join(this.#root, "trash"))) {
signal.throwIfAborted();
await this.#removeRetired(join(this.#root, "trash", name)).catch(
() => undefined,
);
}
}
/** Evict least recently used entries above the cap, keeping pinned ones. */
#evict(keep: string | null): Promise<void> {
const eviction = this.#eviction.then(() => this.#evictUnpinned(keep));
this.#eviction = eviction.catch(() => undefined);
return eviction;
}
/** Coalesce deferred reclamation after callers have had a turn to pin results. */
#scheduleEviction(): void {
this.#maintenanceRequested = true;
if (this.#maintenance !== undefined) return;
const maintenance = (async () => {
while (this.#maintenanceRequested) {
await new Promise<void>((resolve) => setImmediate(resolve));
this.#maintenanceRequested = false;
await this.#evict(null).catch(() => undefined);
}
})();
this.#maintenance = maintenance;
void maintenance.then(() => {
if (this.#maintenance === maintenance) this.#maintenance = undefined;
if (this.#maintenanceRequested) this.#scheduleEviction();
});
}
async #evictUnpinned(keep: string | null): Promise<void> {
// A successful build must reach its callers before they can pin its entry.
// Protect every unfinished build while reading this eviction snapshot.
const building = new Set([...this.#unfinished].map(({ key }) => key));
const entries: { key: string; bytes: number; mtimeMs: number }[] = [];
for (const name of await readdir(join(this.#root, "projects"))) {
if (!KEY_PATTERN.test(name)) continue;
const path = join(this.#root, "projects", name);
const status = await stat(path).catch(() => undefined);
if (status === undefined) continue;
const manifest = await this.#readBounded(join(path, "manifest.json"))
.then(parseManifest)
.catch(() => undefined);
// An owned entry without a valid manifest is unusable; evict it first.
entries.push(
manifest === undefined
? { key: name, bytes: 0, mtimeMs: -1 }
: {
key: name,
bytes: manifestBytes(manifest),
mtimeMs: status.mtimeMs,
},
);
}
let total = entries.reduce((sum, entry) => sum + entry.bytes, 0);
let deferredByBuild = false;
for (const entry of entries.sort((a, b) => a.mtimeMs - b.mtimeMs)) {
if (total <= this.#maxBytes && entry.mtimeMs >= 0) break;
if (building.has(entry.key) || this.#inflight.has(entry.key)) {
deferredByBuild = true;
continue;
}
if (entry.key === keep || this.#pins.has(entry.key)) continue;
await this.#discard(entry.key);
total -= entry.bytes;
}
this.#capacity =
total <= this.#maxBytes
? "within_limit"
: deferredByBuild
? "building"
: "pinned";
}
/** Atomically retire one owned entry, then remove it. */
async #discard(key: string): Promise<void> {
this.#verified.delete(key);
const trash = join(this.#root, "trash", `${key}-${randomUUID()}`);
try {
await mkdir(join(this.#root, "trash"), { recursive: true, mode: 0o700 });
await rename(join(this.#root, "projects", key), trash);
} catch (cause: unknown) {
if (isMissing(cause)) return;
throw cause;
}
await this.#removeRetired(trash, key);
}
/** Keep each failed removal owned until that path is successfully removed. */
async #removeRetired(path: string, key?: string): Promise<void> {
try {
await rm(path, { recursive: true, force: true });
this.#retainedFiles.delete(path);
} catch (cause: unknown) {
throw this.#retainFileCleanup(path, cause, key);
}
}
/** Mark an entry recently used; false when it is no longer published. */
async #touch(key: string): Promise<boolean> {
const now = new Date();
try {
await utimes(join(this.#root, "projects", key), now, now);
return true;
} catch (cause: unknown) {
// Recency is advisory; only a missing entry changes the answer.
return !isMissing(cause);
}
}
async #readBounded(path: string): Promise<string> {
const status = await stat(path);
if (status.size > MANIFEST_MAX_BYTES)
throw new RangeError(
`${path} exceeds ${String(MANIFEST_MAX_BYTES)} bytes`,
);
return readFile(path, "utf8");
}
#retainFileCleanup(
path: string,
cause: unknown,
key?: string,
): ProviderCleanupError {
const error = new ProviderCleanupError(
KUNA_PROVIDER_ID,
[path],
{ reason: cause instanceof Error ? cause.message : String(cause) },
{ operation: KUNA_EXPORT_OPERATION, cause },
);
this.#retainedFiles.set(path, {
key: key ?? this.#retainedFiles.get(path)?.key ?? null,
error,
});
return error;
}
}
/** One cleanup error aggregating what remains owned, or success when nothing does. */
const cleanupResult = (
remaining: readonly ProviderCleanupError[],
): Result<null, ProviderCleanupError> => {
if (remaining.length === 0) return ok(null);
return err(
new ProviderCleanupError(
KUNA_PROVIDER_ID,
remaining.flatMap((entry) => entry.cleanupResources),
{
reason: remaining
.map((entry) =>
String(entry.diagnostics?.["reason"] ?? entry.message),
)
.join("; "),
},
{ operation: KUNA_EXPORT_OPERATION },
),
);
};