Files
rea/tests/boundary/filesystem/jsonOutputOwnership.test.ts
morluto 7285e15839 fix(workflows): retain staged output ownership and cancellation semantics
Give atomic Evidence and snapshot writes an explicit resource owner, preserve primary failures and completed publication receipts, and retry retained staging cleanup at the owning lifecycle. Verify inode identity before publishing or deleting; unknown path disappearance remains a cleanup failure. Keep snapshot admission reusable when a session closes and reopens.

Consolidate JavaScript source-scope classification so cancellation cannot fabricate failures for unselected files. Classify configuration failures as invalid input. Remove stale assertions and reuse shared test cleanup ownership.

Validation: 266 focused tests before final ownership tightening, 108 affected tests afterward, static and documentation checks, actual invalid-configuration CLI behavior, and isolated test temporary-directory hygiene.

Refs #1968
2026-10-12 06:29:27 +08:00

274 lines
10 KiB
TypeScript

import { readdirSync, renameSync, writeFileSync } from "node:fs";
import { mkdir, open, rm } from "node:fs/promises";
import * as fs from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import { EvidenceFileError } from "../../../src/domain/evidenceErrors.js";
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
import { OwnedStagedOutput } from "../../../src/filesystem/OwnedStagedOutput.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { writeTextParts } from "../../../src/application/JsonFiles.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it("reports committed output when post-publication staging cleanup remains owned", async () => {
const root = await createTestTempDirectory("rea-json-write-");
const resources = new ArtifactResourceScope();
try {
const result = await writeTextParts(
stagingPollutingParts(root),
join(root, "bundle.json"),
false,
resources,
);
if (result.ok) throw new Error("Expected staged cleanup failure");
const cleanupResource = result.error.cleanup?.resources[0];
if (cleanupResource === undefined)
throw new Error("Expected retained staging resource");
expect(result.error).toBeInstanceOf(EvidenceFileError);
expect(result.error.published).toMatchObject({
path: join(root, "bundle.json"),
bytes: 16,
});
expect(cleanupResource).toContain(".rea-write-");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cleanup_incomplete",
details: { published: true, published_path: join(root, "bundle.json") },
});
expect(await fs.readFile(join(root, "bundle.json"), "utf8")).toBe(
"canonical output",
);
await fs.rm(join(cleanupResource, "unexpected"));
} finally {
await resources.close();
await rm(root, { recursive: true, force: true });
}
});
it("preserves cancellation when staging cleanup also fails", async () => {
const root = await createTestTempDirectory("rea-json-cancel-");
const resources = new ArtifactResourceScope();
const controller = new AbortController();
try {
function* cancelledParts(): Generator<string> {
const directories = requireStages(root);
if (directories.length !== 1)
throw new Error("Expected one staging directory");
writeFileSync(join(root, directories[0]!, "unexpected"), "retain this");
controller.abort();
yield "must not publish";
}
const result = await writeTextParts(
cancelledParts(),
join(root, "cancelled.json"),
false,
resources,
{ signal: controller.signal, operation: "export_evidence_bundle" },
);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cause).toMatchObject({
cause: expect.any(AnalysisCancelledError),
owner: { directory: expect.stringContaining(".rea-write-") },
});
await expect(fs.access(join(root, "cancelled.json"))).rejects.toThrow();
const stage = requireStages(root)[0];
if (stage !== undefined) await fs.rm(join(root, stage, "unexpected"));
} finally {
await resources.close();
await rm(root, { recursive: true, force: true });
}
});
it("refuses to publish a staged path replaced after its descriptor opened", async () => {
const root = await createTestTempDirectory("rea-staged-swap-");
const resources = new ArtifactResourceScope();
const destination = join(root, "swapped.json");
try {
const result = await writeTextParts(
replacingStagedParts(root),
destination,
false,
resources,
);
if (result.ok) throw new Error("Expected staged identity rejection");
expect(
await fs.readFile(join(root, requireStages(root)[0]!, "content"), "utf8"),
).toBe("foreign bytes");
await expect(fs.access(destination)).rejects.toThrow();
const stage = requireStages(root)[0];
if (stage !== undefined) {
const directory = join(root, stage);
await fs.rm(join(directory, "content"));
await fs.rename(join(directory, "original"), join(directory, "content"));
}
} finally {
await resources.close();
await rm(root, { recursive: true, force: true });
}
});
it("does not invent a staged owner when the supplied scope is closed", async () => {
const root = await createTestTempDirectory("rea-closed-output-scope-");
const resources = new ArtifactResourceScope();
await resources.close();
const destination = join(root, "closed.json");
const result = await writeTextParts(
["unused"],
destination,
false,
resources,
);
if (result.ok) throw new Error("Expected closed-scope admission failure");
expect(result.error).toBeInstanceOf(EvidenceFileError);
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
expect(await fs.readdir(root)).toEqual([]);
});
function stagingPollutingParts(root: string): Iterable<string> {
return {
*[Symbol.iterator](): Generator<string> {
const directories = readdirSync(root);
const stage = directories.find((entry) =>
entry.startsWith(".rea-write-"),
);
if (stage === undefined) throw new Error("Expected staging directory");
writeFileSync(join(root, stage, "unexpected"), "retain this");
yield "canonical output";
},
};
}
function replacingStagedParts(root: string): Iterable<string> {
return {
*[Symbol.iterator](): Generator<string> {
const stage = requireStages(root)[0];
if (stage === undefined) throw new Error("Expected staging directory");
const content = join(root, stage, "content");
renameSync(content, join(root, stage, "original"));
writeFileSync(content, "foreign bytes");
yield "owned bytes";
},
};
}
const requireStages = (root: string): string[] =>
readdirSync(root).filter((entry) => entry.startsWith(".rea-write-"));
it("retains a failed file close owner and retries before removing its stage", async () => {
const root = await createTestTempDirectory("rea-staged-close-");
const directory = join(root, ".rea-write-owned");
await mkdir(directory);
const path = join(directory, "content");
const handle = await open(path, "wx", 0o600);
const owner = await OwnedStagedOutput.create(directory);
let closeAttempts = 0;
const actualClose = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
closeAttempts += 1;
if (closeAttempts === 1) throw new Error("injected close failure");
await actualClose();
});
await owner.setFile(new OwnedFileHandle(handle));
const resources = new ArtifactResourceScope();
const stagedOwner = {
kind: "staged-output" as const,
resource: directory,
output: owner,
};
try {
expect(await resources.release(stagedOwner)).toMatchObject({
kind: "failed",
});
await resources.close();
expect(closeAttempts).toBe(2);
await expect(fs.access(directory)).rejects.toThrow();
} finally {
await resources.close();
await rm(root, { recursive: true, force: true });
}
});
it("refuses to remove unexpected files from an owned staging directory", async () => {
const root = await createTestTempDirectory("rea-staged-replaced-");
const directory = join(root, ".rea-write-owned");
await mkdir(directory);
const owner = await OwnedStagedOutput.create(directory);
const unexpected = join(directory, "unexpected");
writeFileSync(unexpected, "unowned");
try {
await expect(owner.close()).rejects.toThrow(/Unexpected staging entries/u);
expect(await fs.readFile(unexpected, "utf8")).toBe("unowned");
} finally {
await fs.rm(unexpected, { force: true });
await owner.close();
await rm(root, { recursive: true, force: true });
}
});
it("retains cleanup when its staging directory was renamed", async () => {
const root = await createTestTempDirectory("rea-staged-renamed-dir-");
const directory = join(root, ".rea-write-owned");
const moved = join(root, ".rea-write-moved");
await mkdir(directory);
const path = join(directory, "content");
const handle = await open(path, "wx", 0o600);
const owner = await OwnedStagedOutput.create(directory);
await owner.setFile(new OwnedFileHandle(handle));
const resources = new ArtifactResourceScope();
const stagedOwner = {
kind: "staged-output" as const,
resource: directory,
output: owner,
};
try {
writeFileSync(join(directory, "unexpected"), "retain this");
expect(await resources.release(stagedOwner)).toMatchObject({
kind: "failed",
});
await fs.rename(directory, moved);
await expect(resources.close()).rejects.toThrow();
await fs.rename(moved, directory);
await rm(join(directory, "unexpected"));
await expect(resources.close()).resolves.toBeUndefined();
} finally {
await resources.close();
await rm(root, { recursive: true, force: true });
}
});
it("retains cleanup when owned staged content was renamed", async () => {
const root = await createTestTempDirectory("rea-staged-renamed-file-");
const directory = join(root, ".rea-write-owned");
const content = join(directory, "content");
const moved = join(directory, "moved-content");
await mkdir(directory);
const handle = await open(content, "wx", 0o600);
const owner = await OwnedStagedOutput.create(directory);
await owner.setFile(new OwnedFileHandle(handle));
const resources = new ArtifactResourceScope();
const stagedOwner = {
kind: "staged-output" as const,
resource: directory,
output: owner,
};
try {
await fs.rename(content, moved);
expect(await resources.release(stagedOwner)).toMatchObject({
kind: "failed",
});
await expect(resources.close()).rejects.toThrow(
/cleanup remains incomplete/u,
);
await fs.rename(moved, content);
await expect(resources.close()).resolves.toBeUndefined();
} finally {
await resources.close();
await rm(root, { recursive: true, force: true });
}
});