mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
98ec00f406
* 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>
369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import {
|
|
mkdir,
|
|
open as fsOpen,
|
|
writeFile,
|
|
type FileHandle,
|
|
} from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
|
|
import { PRODUCT_IDENTITY } from "../../../src/identity.js";
|
|
import { createDoctorHostFixture } from "../../../src/application/Doctor.fixture.js";
|
|
import {
|
|
runDoctorOwned,
|
|
type DoctorHost,
|
|
} from "../../../src/application/Doctor.js";
|
|
import {
|
|
readSkillFile,
|
|
skillDestinations,
|
|
} from "../../../src/application/SetupSkill.js";
|
|
import { RegularFileCleanupFailure } from "../../../src/filesystem/OwnedRegularFile.js";
|
|
import { systemSetupHost } from "../../../src/application/SetupHost.js";
|
|
import { runSetup } from "../../../src/application/Setup.js";
|
|
import { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
|
|
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
|
import { ConfigurationError } from "../../../src/domain/configurationErrors.js";
|
|
import {
|
|
runUninstall,
|
|
systemUninstallHost,
|
|
} from "../../../src/application/Uninstall.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
vi.mock("node:fs/promises", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
|
return { ...actual, open: vi.fn(actual.open) };
|
|
});
|
|
|
|
const openMock = vi.mocked(fsOpen);
|
|
let actualFs: typeof import("node:fs/promises");
|
|
|
|
beforeEach(async () => {
|
|
actualFs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
openMock.mockImplementation((...args) => actualFs.open(...args));
|
|
});
|
|
|
|
afterEach(() => openMock.mockReset());
|
|
|
|
const failFirstCloses = (path: string) => {
|
|
const attempts: FileHandle[] = [];
|
|
let allowClose = false;
|
|
openMock.mockImplementation(async (...args) => {
|
|
const handle = await actualFs.open(...args);
|
|
if (String(args[0]) !== path) return handle;
|
|
const nativeClose = handle.close.bind(handle);
|
|
vi.spyOn(handle, "close").mockImplementation(async () => {
|
|
attempts.push(handle);
|
|
if (!allowClose) throw new Error("test holds the skill handle open");
|
|
await nativeClose();
|
|
});
|
|
return handle;
|
|
});
|
|
return {
|
|
attempts,
|
|
release: () => {
|
|
allowClose = true;
|
|
},
|
|
};
|
|
};
|
|
|
|
describe("setup skill backup cleanup ownership", () => {
|
|
it.each(["first", "recovery", "verification"] as const)(
|
|
"retains a failed %s backup read until setup host disposal",
|
|
async (stage) => {
|
|
const home = await createTestTempDirectory(
|
|
"rea-setup-skill-backup-close-",
|
|
);
|
|
const [destination] = skillDestinations(home, [], {}, "linux");
|
|
if (destination === undefined)
|
|
throw new Error("Shared skill root missing");
|
|
const manifest = join(destination.path, "SKILL.md");
|
|
const original = "stale managed skill\n";
|
|
const firstBackup = `${manifest}.rea.backup`;
|
|
const backup =
|
|
stage === "recovery"
|
|
? `${firstBackup}.${createHash("sha256").update(original).digest("hex")}.recovery`
|
|
: firstBackup;
|
|
await mkdir(dirname(manifest), { recursive: true });
|
|
await writeFile(manifest, original);
|
|
if (stage === "recovery")
|
|
await writeFile(firstBackup, "historical skill\n");
|
|
if (stage !== "verification") await writeFile(backup, original);
|
|
const close = failFirstCloses(backup);
|
|
const host = systemSetupHost(
|
|
{ platform: "linux", homeDirectory: home } as DoctorHost,
|
|
{ HOME: home },
|
|
);
|
|
|
|
try {
|
|
const result = await host.installSkill([]);
|
|
expect(result).toMatchObject({ ok: false, error: { stage: "backup" } });
|
|
expect(close.attempts.length).toBeGreaterThan(0);
|
|
expect(new Set(close.attempts).size).toBe(1);
|
|
const opensBeforeAdmission = openMock.mock.calls.length;
|
|
expect((await host.installSkill([])).ok).toBe(false);
|
|
expect(openMock).toHaveBeenCalledTimes(opensBeforeAdmission);
|
|
expect(new Set(close.attempts).size).toBe(1);
|
|
expect(await actualFs.readFile(manifest, "utf8")).toBe(original);
|
|
const report = await host.close?.();
|
|
expect(report?.cleanup?.reason).toBe(
|
|
"test holds the skill handle open",
|
|
);
|
|
expect(report?.cleanupResources).toEqual([backup]);
|
|
const primary = new ConfigurationError(
|
|
"Controlled setup state failure",
|
|
{
|
|
settings: [
|
|
{ setting: "fixture", constraint: "Controlled primary failure" },
|
|
],
|
|
},
|
|
);
|
|
const failure = await runSetup(
|
|
{ approved: true, installHopper: false, structured: true },
|
|
{
|
|
...host,
|
|
initialSetupState: async () => {
|
|
throw primary;
|
|
},
|
|
},
|
|
).then(
|
|
() => undefined,
|
|
(cause: unknown) => cause,
|
|
);
|
|
expect(failure).toBeInstanceOf(AnalysisError);
|
|
if (!(failure instanceof AnalysisError))
|
|
throw new Error("Expected the typed setup and host cleanup failure");
|
|
expect(projectAnalysisError(failure)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
resources: [backup],
|
|
diagnostics: {
|
|
cleanup_error: { details: { resources: [backup] } },
|
|
},
|
|
},
|
|
});
|
|
expect(failure.diagnostics?.primary_error).toEqual(
|
|
projectAnalysisError(primary),
|
|
);
|
|
const attemptsBeforeClose = close.attempts.length;
|
|
close.release();
|
|
expect(await host.close?.()).toBeUndefined();
|
|
expect(failure.cleanupIncomplete).toBe(false);
|
|
expect(projectAnalysisError(failure)).toMatchObject(
|
|
projectAnalysisError(primary),
|
|
);
|
|
expect(projectAnalysisError(failure).details).not.toHaveProperty(
|
|
"cleanup",
|
|
);
|
|
expect(projectAnalysisError(failure).details).not.toHaveProperty(
|
|
"resources",
|
|
);
|
|
expect(projectAnalysisError(failure).details).toHaveProperty(
|
|
"cleanup_history",
|
|
);
|
|
expect(close.attempts.length).toBeGreaterThan(attemptsBeforeClose);
|
|
expect(close.attempts[0]?.fd).toBe(-1);
|
|
} finally {
|
|
close.release();
|
|
await host.close?.();
|
|
// Reclaim the real test descriptor even when the retention regression fails.
|
|
for (const handle of new Set(close.attempts)) await handle.close();
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("skill regular-file cleanup ownership", () => {
|
|
it("retains a failed setup skill read until setup host disposal", async () => {
|
|
const home = await createTestTempDirectory("rea-setup-skill-close-");
|
|
const [destination] = skillDestinations(home, [], {}, "linux");
|
|
if (destination === undefined) throw new Error("Shared skill root missing");
|
|
const manifest = join(destination.path, "SKILL.md");
|
|
await mkdir(dirname(manifest), { recursive: true });
|
|
await writeFile(manifest, "stale managed skill\n");
|
|
const close = failFirstCloses(manifest);
|
|
const host = systemSetupHost(
|
|
{ platform: "linux", homeDirectory: home } as DoctorHost,
|
|
{ HOME: home },
|
|
);
|
|
|
|
try {
|
|
const failure = await host.skillNeedsInstall([]).then(
|
|
() => undefined,
|
|
(cause: unknown) => cause,
|
|
);
|
|
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
|
|
expect(failure).toMatchObject({
|
|
path: manifest,
|
|
outcome: { kind: "completed" },
|
|
cleanupCause: expect.objectContaining({
|
|
message: "test holds the skill handle open",
|
|
}),
|
|
});
|
|
expect(close.attempts.length).toBeGreaterThan(0);
|
|
expect(new Set(close.attempts).size).toBe(1);
|
|
const attemptsBeforeClose = close.attempts.length;
|
|
close.release();
|
|
expect(await host.close?.()).toBeUndefined();
|
|
expect(close.attempts.length).toBeGreaterThan(attemptsBeforeClose);
|
|
expect(new Set(close.attempts).size).toBe(1);
|
|
expect(close.attempts[0]?.fd).toBe(-1);
|
|
} finally {
|
|
close.release();
|
|
await host.close?.();
|
|
}
|
|
});
|
|
|
|
it("waits for an admitted skill read before its resource scope closes", async () => {
|
|
const home = await createTestTempDirectory("rea-setup-skill-inflight-");
|
|
const [destination] = skillDestinations(home, []);
|
|
if (destination === undefined) throw new Error("Shared skill root missing");
|
|
const manifest = join(destination.path, "SKILL.md");
|
|
await mkdir(dirname(manifest), { recursive: true });
|
|
await writeFile(manifest, "stale managed skill\n");
|
|
const resources = new ArtifactResourceScope();
|
|
let beginRead!: () => void;
|
|
const readStarted = new Promise<void>((resolve) => {
|
|
beginRead = resolve;
|
|
});
|
|
let unblockRead!: () => void;
|
|
const readGate = new Promise<void>((resolve) => {
|
|
unblockRead = resolve;
|
|
});
|
|
let heldRead = false;
|
|
openMock.mockImplementation(async (...args) => {
|
|
const handle = await actualFs.open(...args);
|
|
if (String(args[0]) !== manifest || heldRead) return handle;
|
|
heldRead = true;
|
|
const nativeRead = handle.read.bind(handle);
|
|
vi.spyOn(handle, "read").mockImplementation(async (...readArgs) => {
|
|
beginRead();
|
|
await readGate;
|
|
return nativeRead(...readArgs);
|
|
});
|
|
return handle;
|
|
});
|
|
|
|
try {
|
|
const reading = readSkillFile(manifest, resources);
|
|
await readStarted;
|
|
let closed = false;
|
|
const closing = resources.close().then(() => {
|
|
closed = true;
|
|
});
|
|
await Promise.resolve();
|
|
expect(closed).toBe(false);
|
|
unblockRead();
|
|
expect(await reading).toBe("stale managed skill\n");
|
|
await closing;
|
|
expect(closed).toBe(true);
|
|
} finally {
|
|
unblockRead();
|
|
await resources.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("skill cleanup diagnostics", () => {
|
|
it("preserves read and host cleanup failures when doctor identity cannot complete", async () => {
|
|
const home = await createTestTempDirectory("rea-doctor-skill-close-");
|
|
const [destination] = skillDestinations(home, []);
|
|
if (destination === undefined) throw new Error("Shared skill root missing");
|
|
const manifest = join(destination.path, "SKILL.md");
|
|
await mkdir(dirname(manifest), { recursive: true });
|
|
await writeFile(manifest, "managed skill fixture\n");
|
|
const close = failFirstCloses(manifest);
|
|
const resources = new ArtifactResourceScope();
|
|
const host = createDoctorHostFixture({
|
|
installedSkillIdentity: async () => {
|
|
await readSkillFile(manifest, resources);
|
|
return { version: null, toolCount: null, canonical: true };
|
|
},
|
|
close: async () => {
|
|
try {
|
|
await resources.close();
|
|
return undefined;
|
|
} catch (cause: unknown) {
|
|
return cause instanceof Error ? cause.message : String(cause);
|
|
}
|
|
},
|
|
});
|
|
|
|
try {
|
|
const failure = await runDoctorOwned(undefined, host, {
|
|
skill: true,
|
|
}).then(
|
|
() => undefined,
|
|
(cause: unknown) => cause,
|
|
);
|
|
expect(failure).toBeInstanceOf(AggregateError);
|
|
if (!(failure instanceof AggregateError))
|
|
throw new Error("Doctor cleanup should preserve both failures");
|
|
expect(failure.errors[0]).toBeInstanceOf(RegularFileCleanupFailure);
|
|
expect(failure.errors[0]).toMatchObject({
|
|
path: manifest,
|
|
outcome: { kind: "completed" },
|
|
});
|
|
expect(failure.errors[1]).toEqual(
|
|
expect.objectContaining({
|
|
message: expect.stringContaining(manifest),
|
|
}),
|
|
);
|
|
expect(close.attempts.length).toBeGreaterThan(0);
|
|
expect(new Set(close.attempts).size).toBe(1);
|
|
const attemptsBeforeClose = close.attempts.length;
|
|
close.release();
|
|
expect(await host.close?.()).toBeUndefined();
|
|
expect(close.attempts.length).toBeGreaterThan(attemptsBeforeClose);
|
|
expect(new Set(close.attempts).size).toBe(1);
|
|
expect(close.attempts[0]?.fd).toBe(-1);
|
|
} finally {
|
|
close.release();
|
|
await host.close?.();
|
|
}
|
|
});
|
|
|
|
it("returns uninstall changes with a failed cleanup item for a retained handle", async () => {
|
|
const home = await createTestTempDirectory("rea-uninstall-skill-close-");
|
|
const [destination] = skillDestinations(home, []);
|
|
if (destination === undefined) throw new Error("Shared skill root missing");
|
|
const manifest = join(destination.path, "SKILL.md");
|
|
await mkdir(dirname(manifest), { recursive: true });
|
|
await writeFile(
|
|
manifest,
|
|
`---\nname: ${PRODUCT_IDENTITY.skillName}\n---\nmanaged fixture\n`,
|
|
);
|
|
const close = failFirstCloses(manifest);
|
|
const host = systemUninstallHost(home, undefined, { HOME: home });
|
|
|
|
try {
|
|
const result = await runUninstall(false, host);
|
|
|
|
expect(result.status).toBe("failed");
|
|
expect(result.items).toContainEqual(
|
|
expect.objectContaining({
|
|
name: "uninstall_cleanup",
|
|
status: "failed",
|
|
detail: expect.stringContaining(manifest),
|
|
}),
|
|
);
|
|
expect(close.attempts.length).toBeGreaterThan(0);
|
|
expect(new Set(close.attempts).size).toBe(1);
|
|
const attemptsBeforeClose = close.attempts.length;
|
|
close.release();
|
|
expect(await host.close?.()).toBeUndefined();
|
|
expect(close.attempts.length).toBeGreaterThan(attemptsBeforeClose);
|
|
expect(new Set(close.attempts).size).toBe(1);
|
|
expect(close.attempts[0]?.fd).toBe(-1);
|
|
} finally {
|
|
close.release();
|
|
await host.close?.();
|
|
}
|
|
});
|
|
});
|