fix(evidence)!: report storage publication limits (#7887)

* fix(evidence): report storage publication limits

* test(evidence): cover staged storage failure cleanup
This commit is contained in:
morluto authored and GitHub committed 2026-10-29 23:03:09 +08:00
1 parent 539a494d25
commit bf115be2cd
6 files changed
+187 -12

No files matched your search

+27 -10
View File
@@ -411,16 +411,16 @@ const writeTextPartsOwned = async (
}),
cause.lease,
);
const failure = classifyWriteFailure(cause.outcome.error);
return stagedCleanupFailure(
new EvidenceFileError(
"write",
fileAccessFailureReason(cause.outcome.error),
{
cause,
path: requestedPath,
cleanup,
},
),
new EvidenceFileError("write", failure.reason, {
cause,
path: requestedPath,
cleanup,
...(failure.systemCode === undefined
? {}
: { systemCode: failure.systemCode }),
}),
cause.lease,
);
}
@@ -437,15 +437,32 @@ const writeTextPartsOwned = async (
path: requestedPath,
}),
);
const failure = classifyWriteFailure(cause);
return err(
new EvidenceFileError("write", fileAccessFailureReason(cause), {
new EvidenceFileError("write", failure.reason, {
cause,
path: requestedPath,
...(failure.systemCode === undefined
? {}
: { systemCode: failure.systemCode }),
}),
);
}
};
/** Recognize only host codes that establish a publication resource refusal. */
const classifyWriteFailure = (
cause: unknown,
): {
readonly reason: EvidenceFileError["reason"];
readonly systemCode?: "EFBIG" | "ENOSPC" | "EDQUOT";
} => {
const code = fileErrorCode(cause);
return code === "EFBIG" || code === "ENOSPC" || code === "EDQUOT"
? { reason: "storage-limit", systemCode: code }
: { reason: fileAccessFailureReason(cause) };
};
/** The retained staging lease clears this error only when a retry confirms release. */
const stagedCleanupFailure = <
Failure extends EvidenceFileError | AnalysisCancelledError,
+11 -1
View File
@@ -260,7 +260,10 @@ export const analysisErrorCategory = (
return "unavailable";
if (error instanceof ArtifactOperationError)
return artifactErrorCategory(error.reason);
if (error instanceof EvidenceFileError && error.reason === "path-limit")
if (
error instanceof EvidenceFileError &&
(error.reason === "path-limit" || error.reason === "storage-limit")
)
return "resource_constraint";
if (
error instanceof EvidenceFileError &&
@@ -565,11 +568,18 @@ const evidenceFileMessage = ({
operation,
reason,
published,
systemCode,
}: EvidenceFileError): string => {
if (published !== undefined)
return "The output file was published, but staging cleanup remains incomplete.";
if (reason === "path-limit")
return "Filesystem path-length constraints prevented reading or atomic publication at the selected path. Choose a shorter local path.";
if (reason === "storage-limit")
return systemCode === "EFBIG"
? "The host file-size limit prevented Evidence publication. Increase the inherited file-size allowance and retry."
: systemCode === undefined
? "A host filesystem resource limit prevented Evidence publication. Check the file-size allowance, filesystem space and quota before retrying."
: "The filesystem lacks space or quota for Evidence publication. Free space or increase quota and retry.";
if (reason === "symlink-traversal")
return "The selected path cannot be resolved because its symbolic-link chain loops or exceeds the host traversal limit. Repair the chain or select another path.";
if (reason === "missing")
+5 -1
View File
@@ -236,7 +236,8 @@ const evidenceFileCode = (
reason: EvidenceFileError["reason"],
): AnalysisErrorProjection["code"] => {
if (reason === "invalid-json") return "evidence_integrity_mismatch";
if (reason === "path-limit") return "resource_constraint";
if (reason === "path-limit" || reason === "storage-limit")
return "resource_constraint";
// A missing, non-file or already-existing path is the caller's selection.
if (reason === "io") return "execution_failure";
return "invalid_request";
@@ -418,6 +419,9 @@ const artifactStateErrorDetails = (
operation: error.operation,
reason: error.reason,
...(error.reason === "path-limit" ? { boundary: "filesystem-path" } : {}),
...(error.systemCode === undefined
? {}
: { system_code: error.systemCode }),
...(error.path === undefined ? {} : { path: error.path }),
...(error.contentIssues === undefined
? {}
+4
View File
@@ -60,6 +60,7 @@ export class EvidenceFileError extends AnalysisError {
readonly published: PublishedEvidenceFileObservation | undefined;
/** Schema issues relative to the selected file's JSON root, never the request envelope. */
readonly contentIssues: readonly AnalysisInputIssue[] | undefined;
readonly systemCode: "EFBIG" | "ENOSPC" | "EDQUOT" | undefined;
constructor(
readonly operation: "read" | "write",
@@ -70,6 +71,7 @@ export class EvidenceFileError extends AnalysisError {
| "invalid-json"
| "missing"
| "path-limit"
| "storage-limit"
| "symlink-traversal"
| "io",
options?: AnalysisErrorOptions & {
@@ -77,6 +79,7 @@ export class EvidenceFileError extends AnalysisError {
readonly published?: PublishedEvidenceFileObservation;
readonly userMessage?: string;
readonly contentIssues?: readonly AnalysisInputIssue[];
readonly systemCode?: "EFBIG" | "ENOSPC" | "EDQUOT";
},
) {
super(`Evidence bundle ${operation} failed: ${reason}`, options);
@@ -84,5 +87,6 @@ export class EvidenceFileError extends AnalysisError {
this.published = options?.published;
this.userMessage = options?.userMessage;
this.contentIssues = options?.contentIssues;
this.systemCode = options?.systemCode;
}
}
+4
View File
@@ -189,6 +189,10 @@ const redactFailure = (
const publishedPath = identity(error.published?.path);
return new EvidenceFileError(error.operation, error.reason, {
...context,
...(error.systemCode === undefined ||
text(error.systemCode) !== error.systemCode
? {}
: { systemCode: error.systemCode }),
...(retainedPath === undefined ? {} : { path: retainedPath }),
...(error.userMessage === undefined
? {}
@@ -0,0 +1,136 @@
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished, vi } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const failure = vi.hoisted(() => ({ code: "" }));
vi.mock("node:fs/promises", async (importOriginal) => {
const filesystem = await importOriginal<typeof import("node:fs/promises")>();
return {
...filesystem,
mkdtemp: async (...args: Parameters<typeof filesystem.mkdtemp>) => {
if (failure.code !== "")
throw Object.assign(new Error("controlled staging refusal"), {
code: failure.code,
});
return filesystem.mkdtemp(...args);
},
};
});
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import { writeTextParts } from "../../../src/application/JsonFiles.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { redactExplicitFailure } from "../../../src/domain/explicitSensitiveFailure.js";
it.each([
["EFBIG", "file-size allowance"],
["ENOSPC", "space or quota"],
["EDQUOT", "space or quota"],
] as const)(
"preserves the %s host resource refusal through redaction",
async (code, guidance) => {
const root = await createTestTempDirectory("rea-write-resource-");
const resources = new ArtifactResourceScope();
onTestFinished(() => resources.close());
failure.code = code;
try {
const result = await writeTextParts(
["bytes"],
join(root, "out.json"),
false,
resources,
);
expect(result.ok).toBe(false);
if (!result.ok) {
const projected = projectAnalysisError(
redactExplicitFailure(result.error, ["unrelated-secret"]),
);
expect(projected).toMatchObject({
code: "resource_constraint",
category: "resource_constraint",
retryable: false,
details: {
operation: "write",
reason: "storage-limit",
system_code: code,
path: join(root, "out.json"),
},
});
expect(projected.remediation.action).toContain(guidance);
const excluded = projectAnalysisError(
redactExplicitFailure(result.error, [code]),
);
expect(excluded.details).not.toHaveProperty("system_code");
expect(excluded.remediation.action).toContain("file-size allowance");
}
expect(await readdir(root)).toEqual([]);
} finally {
failure.code = "";
}
},
);
/** Fail after the first staged part has been written, before publication. */
function* limitedParts(): Generator<string> {
yield "staged bytes";
throw Object.assign(new Error("file size limit"), { code: "EFBIG" });
}
it.each([false, true])(
"retains a staged EFBIG and cleans or retries its owner (cleanup fails: %s)",
async (cleanupFails) => {
const root = await createTestTempDirectory("rea-staged-resource-");
const output = join(root, "out.json");
const resources = new ArtifactResourceScope();
const { OwnedStagedOutput } =
await import("../../../src/filesystem/OwnedStagedOutput.js");
const close = vi.spyOn(OwnedStagedOutput.prototype, "close");
if (cleanupFails)
close.mockRejectedValueOnce(new Error("controlled cleanup refusal"));
try {
const result = await writeTextParts(
limitedParts(),
output,
false,
resources,
);
if (result.ok) throw new Error("Expected resource refusal");
const redacted = redactExplicitFailure(result.error, [
"unrelated-secret",
]);
const projected = projectAnalysisError(redacted);
expect(projected).toMatchObject({
code: cleanupFails ? "cleanup_incomplete" : "resource_constraint",
details: {
reason: "storage-limit",
system_code: "EFBIG",
path: output,
},
});
const staging = (await readdir(root)).filter((name) =>
name.startsWith(".rea-write-"),
);
expect(staging).toHaveLength(cleanupFails ? 1 : 0);
expect((await readdir(root)).includes("out.json")).toBe(false);
if (cleanupFails) {
expect(resources.hasPending).toBe(true);
expect(redacted.cleanupResources).toContain(join(root, staging[0]!));
await resources.retryCleanup();
expect(resources.hasPending).toBe(false);
expect(redacted.cleanupIncomplete).toBe(false);
expect(projectAnalysisError(redacted)).toMatchObject({
code: "resource_constraint",
details: {
reason: "storage-limit",
system_code: "EFBIG",
path: output,
},
});
expect(await readdir(root)).toEqual([]);
}
} finally {
close.mockRestore();
await resources.close();
}
},
);