mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 01:46:15 +02:00
## Thinking Path > - Paperclip manages AI agents and the work they produce. > - Sandbox work must return to the host when an agent run ends. > - A failed transfer must keep its source available for recovery. > - Some transfer errors lose their useful fields before restore diagnostics reach Sentry. > - This change records fixed transfer stages, failure kinds, and numeric RPC codes. > - The next failure can identify the operation that failed without exposing workspace contents. ## Linked Issues or Issue Description **What happened?** A workspace transfer can fail after model execution succeeds. Several command, archive validation, and download errors become plain errors. The existing RPC envelope then reports `unknown`, with no transfer stage or command exit code. A host RPC timeout also loses its code from restore diagnostics. **Expected behavior** Keep bounded evidence through the provider, worker RPC, restore result, and Sentry context. Keep the same exception, error message, retry rules, and source retention policy. A transfer timeout must remain separate from the model execution timeout flag. **Steps to reproduce** Return a nonzero exit code from the sandbox archive command, return a per-file download error, exceed a tar listing limit, or time out the sync-out RPC. Observe the missing fields in the saved restore diagnostic. The added tests use local fixtures for these cases. Related public work: #15479 preserves the source after restore failure. #15481 carries bounded sync-out diagnostics through RPC. This change supplies missing producer evidence and extends that same envelope. I checked the roadmap and searched open PRs for duplicate transfer diagnostic work. ## What Changed - Add optional transfer stage and failure kind fields to the existing diagnostic envelope. Capture command exits and listing deadlines at their producers. - Record only known codes from typed RPC errors at the sync-out boundary. Revalidate every field before persistence and Sentry projection. - Keep annotations private to each outbound request and restore settlement. Preserve frozen error identity and prevent evidence from leaking across concurrent or later calls. - Document the fields. Cover producer failures, quota limits, old workers, concurrent error reuse, and redaction through the real Sentry SDK. ## Verification - Focused SDK, provider, host, persistence, and real Sentry tests: 449 passed, no skips. Independent review also ran the focused contracts and all provider tests. The optional Sentry SDK is required for this check, so the contract tests cannot skip. - A compiled Daytona transfer and compiled SDK worker pass a local RPC round trip. This uses the development TypeScript loader for workspace dependency exports. The transfer and capture modules are compiled JavaScript. - `pnpm -r typecheck` and `pnpm build` passed. The excluded Daytona package also passed `tsc --noEmit`. - The initial local `pnpm test:run` stopped in the general-server group: 60 suites could not initialize embedded PostgreSQL because the isolated install skipped its Darwin library-link setup. The existing package postinstall repairs this; `initdb --version` now passes. All 60 affected suites then passed: 1,342 tests, no skips. No local full aggregate pass is claimed. - Independent source review covers privacy, concurrency, packaging, and recovery behavior. ## Risks - These diagnostics help identify future failures. They do not establish or repair the cause of a past transfer failure. - New fields are optional. Older workers remain compatible. Unknown values are omitted. - The change adds a small request-local diagnostic scope. It keeps the original thrown errors, archive confinement, cleanup order, timeout values, retry count, and source retention policy. - No schema or deployment changes are required. No live provider actions are part of validation. ## Model Used OpenAI Codex, based on GPT-6, with repository inspection, code execution, and independent agent review. The exact deployed model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
330 lines
20 KiB
TypeScript
330 lines
20 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { classifyWorkspaceRestoreFailure } from "./workspace-restore-merge.js";
|
|
import {
|
|
withWorkspaceRestoreDiagnosticCapture, getWorkspaceRestoreDiagnostic, preserveWorkspaceRestoreErrorDiagnostic, recordWorkspaceRestoreDiagnostic,
|
|
sanitizeWorkspaceRestoreDiagnostic, withWorkspaceRestoreDiagnostics, withWorkspaceRestoreStep, withWorkspaceRestoreGitCommand,
|
|
type WorkspaceRestoreDiagnostic,
|
|
} from "./workspace-restore-diagnostics.js";
|
|
|
|
describe("workspace restore diagnostics", () => {
|
|
it.each([new Error("shared nested failure"), "shared primitive failure"])("selects an unlabelled nested failure without retaining a later task's step (%s)", async (error) => {
|
|
const sink = vi.fn();
|
|
const snapshots: Array<WorkspaceRestoreDiagnostic | undefined> = [];
|
|
await expect(withWorkspaceRestoreDiagnostics("workspace", async () => {
|
|
await Promise.allSettled([
|
|
withWorkspaceRestoreDiagnostics("workspace", async () => { throw error; }, sink, (value) => { snapshots[0] = value; }),
|
|
withWorkspaceRestoreDiagnostics("asset", () => withWorkspaceRestoreStep("asset_restore", async () => { throw error; }), sink,
|
|
(value) => { snapshots[1] = value; }),
|
|
]);
|
|
recordWorkspaceRestoreDiagnostic(error, snapshots[0]);
|
|
throw error;
|
|
}, sink)).rejects.toBe(error);
|
|
expect(snapshots[1]?.step).toBe("asset_restore");
|
|
expect(getWorkspaceRestoreDiagnostic(error)).toEqual(typeof error === "object" ? { phase: "workspace", errorCode: "unknown" } : undefined);
|
|
expect(sink).toHaveBeenCalledExactlyOnceWith('[paperclip] Workspace restore diagnostic: {"phase":"workspace","errorCode":"unknown"}\n');
|
|
});
|
|
|
|
it("keeps logging when a diagnostic consumer fails", async () => {
|
|
const error = new Error("restore failed");
|
|
const sink = vi.fn();
|
|
await expect(withWorkspaceRestoreDiagnostics("workspace", async () => { throw error; }, sink,
|
|
() => { throw new Error("consumer failed"); })).rejects.toBe(error);
|
|
expect(sink).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("retains a nested step and bounded Git fields across an existing wrapper without a cause", async () => {
|
|
const source = Object.assign(new Error("private command/path"), { code: 1, stderr: "private stderr" });
|
|
const wrapper = new Error("existing wrapper");
|
|
await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("directory_merge", async () => {
|
|
try {
|
|
await withWorkspaceRestoreStep("git_integration", async () => { throw source; });
|
|
} catch (error) { throw preserveWorkspaceRestoreErrorDiagnostic(wrapper, error); }
|
|
}))).rejects.toBe(wrapper);
|
|
expect(wrapper).not.toHaveProperty("cause");
|
|
expect(Object.keys(wrapper)).toEqual([]);
|
|
expect(getWorkspaceRestoreDiagnostic(wrapper)).toEqual({ phase: "workspace", step: "git_integration", errorCode: "unknown", exitCode: 1 });
|
|
});
|
|
|
|
it("attributes a reused error to the later failed retry step", async () => {
|
|
const error = new Error("same failure");
|
|
await expect(withWorkspaceRestoreDiagnostics("workspace", async () => {
|
|
await withWorkspaceRestoreStep("git_import", async () => { throw error; }).catch(() => {});
|
|
await withWorkspaceRestoreStep("git_export", async () => { throw error; });
|
|
})).rejects.toBe(error);
|
|
expect(getWorkspaceRestoreDiagnostic(error)?.step).toBe("git_export");
|
|
await expect(withWorkspaceRestoreDiagnostics("asset", async () => { throw error; })).rejects.toBe(error);
|
|
expect(getWorkspaceRestoreDiagnostic(error)).toEqual({ phase: "asset", errorCode: "unknown" });
|
|
});
|
|
|
|
it("isolates concurrent steps even when their errors share identity", async () => {
|
|
const error = new Error("shared error");
|
|
const sink = vi.fn();
|
|
await Promise.allSettled([
|
|
withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("git_import", async () => { throw error; }), sink),
|
|
withWorkspaceRestoreDiagnostics("asset", () => withWorkspaceRestoreStep("asset_restore", async () => { throw error; }), sink),
|
|
]);
|
|
expect(sink.mock.calls.map(([line]) => JSON.parse(line.split(": ")[1]))).toEqual(expect.arrayContaining([
|
|
{ phase: "workspace", step: "git_import", errorCode: "unknown" },
|
|
{ phase: "asset", step: "asset_restore", errorCode: "unknown" },
|
|
]));
|
|
});
|
|
|
|
it("starts a fresh scope for later work inherited from a completed async context", async () => {
|
|
const error = new Error("later failure");
|
|
let release!: () => void;
|
|
const gate = new Promise<void>((resolve) => { release = resolve; });
|
|
let later!: Promise<unknown>;
|
|
const sink = vi.fn();
|
|
await withWorkspaceRestoreDiagnostics("workspace", async () => {
|
|
later = gate.then(() => withWorkspaceRestoreDiagnostics("asset", () =>
|
|
withWorkspaceRestoreStep("asset_restore", async () => { throw error; }), sink)).catch((value) => value);
|
|
}, sink);
|
|
release();
|
|
expect(await later).toBe(error);
|
|
expect(getWorkspaceRestoreDiagnostic(error)).toEqual({ phase: "asset", step: "asset_restore", errorCode: "unknown" });
|
|
expect(sink).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("leaves successful restores silent", async () => {
|
|
const sink = vi.fn();
|
|
expect(await withWorkspaceRestoreDiagnostics("workspace", async () => 42, sink)).toBe(42);
|
|
expect(sink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("records bounded cause fields without copying messages or arbitrary codes", async () => {
|
|
const sink = vi.fn();
|
|
const error = Object.assign(new Error("private path and credential"), {
|
|
code: "private-code", url: "https://private.invalid/secret", body: "private body",
|
|
cause: Object.assign(new Error("private cause"), { code: "ECONNRESET", statusCode: 503, exitCode: 7 }),
|
|
});
|
|
await expect(withWorkspaceRestoreDiagnostics("asset", async () => { throw error; }, sink)).rejects.toBe(error);
|
|
expect(sink).toHaveBeenCalledExactlyOnceWith(
|
|
'[paperclip] Workspace restore diagnostic: {"phase":"asset","errorCode":"ECONNRESET","httpStatus":503,"exitCode":7}\n',
|
|
);
|
|
});
|
|
|
|
it.each([null, "private string", { code: "token", status: "401", exitCode: Infinity }, { status: 200, exitCode: -1 }])(
|
|
"omits unrecognized diagnostic values (%j)", async (error) => {
|
|
const sink = vi.fn();
|
|
await expect(withWorkspaceRestoreDiagnostics("asset", async () => { throw error; }, sink)).rejects.toBe(error);
|
|
expect(sink).toHaveBeenCalledExactlyOnceWith(
|
|
'[paperclip] Workspace restore diagnostic: {"phase":"asset","errorCode":"unknown"}\n',
|
|
);
|
|
},
|
|
);
|
|
|
|
it("bounds cause traversal even when it cycles", async () => {
|
|
const error = Object.assign(new Error("private"), { cause: null as unknown, code: "ENOENT" });
|
|
error.cause = error;
|
|
const sink = vi.fn();
|
|
await expect(withWorkspaceRestoreDiagnostics("asset", async () => { throw error; }, sink)).rejects.toBe(error);
|
|
expect(sink.mock.calls[0]?.[0]).toContain('"errorCode":"ENOENT"');
|
|
});
|
|
|
|
it.each(["EACCES", "WORKSPACE_RESTORE_UNSAFE_ARCHIVE"])("preserves %s when logging fails", async (code) => {
|
|
const error = Object.assign(new Error("private"), { code });
|
|
const classification = classifyWorkspaceRestoreFailure(error);
|
|
await expect(withWorkspaceRestoreDiagnostics("workspace", async () => { throw error; }, async () => { throw new Error("sink failed"); }))
|
|
.rejects.toBe(error);
|
|
expect(classifyWorkspaceRestoreFailure(error)).toBe(classification);
|
|
});
|
|
|
|
it("keeps the restore error even if a provider diagnostic getter throws", async () => {
|
|
const error = Object.defineProperty(Object.assign(new Error("original"), {
|
|
statusCode: 503, cause: { code: "ECONNRESET" },
|
|
}), "code", { get() { throw new Error("getter failed"); } });
|
|
const sink = vi.fn();
|
|
await expect(withWorkspaceRestoreDiagnostics("asset", async () => { throw error; }, sink)).rejects.toBe(error);
|
|
expect(sink).toHaveBeenCalledExactlyOnceWith(
|
|
'[paperclip] Workspace restore diagnostic: {"phase":"asset","errorCode":"ECONNRESET","httpStatus":503}\n',
|
|
);
|
|
});
|
|
|
|
it("uses valid fallback statuses when preferred fields are invalid", async () => {
|
|
const error = { status: "failed", statusCode: 503, exitCode: "failed", code: 7 };
|
|
const sink = vi.fn();
|
|
await expect(withWorkspaceRestoreDiagnostics("workspace", async () => { throw error; }, sink)).rejects.toBe(error);
|
|
expect(sink).toHaveBeenCalledExactlyOnceWith(
|
|
'[paperclip] Workspace restore diagnostic: {"phase":"workspace","errorCode":"unknown","httpStatus":503,"exitCode":7}\n',
|
|
);
|
|
});
|
|
|
|
it("logs nested failures once while preserving independent concurrent and later diagnostics", async () => {
|
|
const error = Object.assign(new Error("nested failure"), { code: "ENOENT" });
|
|
const sink = vi.fn();
|
|
const fail = async () => { await Promise.resolve(); throw error; };
|
|
const results = await Promise.allSettled([
|
|
withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreDiagnostics("workspace", fail, sink), sink),
|
|
withWorkspaceRestoreDiagnostics("asset", fail, sink),
|
|
]);
|
|
expect(results).toEqual([{ status: "rejected", reason: error }, { status: "rejected", reason: error }]);
|
|
expect(sink).toHaveBeenCalledTimes(2);
|
|
expect(sink.mock.calls.map(([line]) => JSON.parse(line.split(": ")[1]).phase).sort()).toEqual(["asset", "workspace"]);
|
|
await expect(withWorkspaceRestoreDiagnostics("workspace", fail, sink)).rejects.toBe(error);
|
|
expect(sink).toHaveBeenCalledTimes(3);
|
|
});
|
|
});
|
|
|
|
describe("Git integration diagnostic privacy and attribution", () => {
|
|
async function capture(command: Parameters<typeof withWorkspaceRestoreGitCommand>[0], error: unknown) {
|
|
let receipt: WorkspaceRestoreDiagnostic | undefined;
|
|
const originalProperties = error && typeof error === "object" ? Object.getOwnPropertyDescriptors(error) : undefined;
|
|
await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("git_integration", () =>
|
|
withWorkspaceRestoreGitCommand(command, async () => { throw error; })), undefined,
|
|
(value) => { receipt = value; })).rejects.toBe(error);
|
|
if (originalProperties) expect(Object.getOwnPropertyDescriptors(error)).toEqual(originalProperties);
|
|
return receipt;
|
|
}
|
|
|
|
it.each([
|
|
["merge_tree", { code: 1, stdout: "a".repeat(40) + "\nprivate-file", stderr: "private-conflict-body" }, "merge_conflict"],
|
|
["merge_tree", { code: 1 }, "unknown"],
|
|
["merge_tree", { code: 1, stderr: `merge-tree: ${"a".repeat(40)} - not something we can merge\n` }, "invalid_object"],
|
|
["merge_tree", { code: 1, signal: "SIGTERM" }, "unknown"],
|
|
["merge_tree", { code: 1, killed: true }, "unknown"],
|
|
["merge_tree", { code: 128, stderr: "fatal: Not a valid object name private-object" }, "invalid_object"],
|
|
["merge_tree", { code: 128, stderr: `merge-tree: ${"a".repeat(40)} - not something we can merge\n` }, "invalid_object"],
|
|
["merge_base", { code: 128, stderr: "fatal: Not a valid commit name private-object" }, "invalid_object"],
|
|
["rev_parse", { code: 128, stderr: "fatal: bad object private-object" }, "invalid_object"],
|
|
["update_ref", { code: 128, stderr: `fatal: update_ref failed for ref 'private-ref': cannot lock ref 'private-ref': is at ${"a".repeat(40)} but expected ${"b".repeat(40)}\n` }, "ref_conflict"],
|
|
["update_ref", { code: 128, stderr: "fatal: Unable to create 'private-path.lock': File exists." }, "unknown"],
|
|
["update_ref", { code: "EACCES", stderr: "private-path" }, "permission_denied"],
|
|
["commit_tree", { code: "EPERM" }, "permission_denied"],
|
|
["commit_tree", { code: 1, stderr: "Permission denied private-path" }, "unknown"],
|
|
["symbolic_ref", { code: 1 }, "unknown"],
|
|
["merge_base", { code: 1 }, "unknown"],
|
|
["log", { code: 128, stderr: "localized or unrecognized private-text" }, "unknown"],
|
|
["log", { code: 128, stderr: "x".repeat(16 * 1024) + "\nfatal: bad object private-object" }, "unknown"],
|
|
["merge_tree", "private-string", "unknown"],
|
|
] as const)("classifies only supported %s evidence (%j)", async (command, error, kind) => {
|
|
const receipt = await capture(command, error);
|
|
expect(receipt).toMatchObject({ gitCommand: command, gitFailureKind: kind });
|
|
expect(JSON.stringify(receipt)).not.toContain("private-");
|
|
expect(Object.keys(receipt!).sort()).toEqual([
|
|
"phase", "step", "errorCode", "gitCommand", "gitFailureKind",
|
|
...(typeof error === "object" && typeof error.code === "number" ? ["exitCode"] : []),
|
|
].sort());
|
|
});
|
|
|
|
it("treats throwing stderr getters as unknown and never replaces the original error", async () => {
|
|
const error = Object.defineProperty(new Error("private-original"), "stderr", { get() { throw new Error("private-getter"); } });
|
|
expect(await capture("commit_tree", error)).toEqual({ phase: "workspace", step: "git_integration",
|
|
errorCode: "unknown", gitCommand: "commit_tree", gitFailureKind: "unknown" });
|
|
});
|
|
|
|
it("retains a nested command across wrappers and nested task diagnostics", async () => {
|
|
const source = Object.assign(new Error("private-source"), { code: 1, stdout: "a".repeat(40) + "\n" });
|
|
const wrapper = new Error("private-wrapper");
|
|
const sink = vi.fn();
|
|
await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("directory_merge", () =>
|
|
withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("git_integration", async () => {
|
|
try { await withWorkspaceRestoreGitCommand("merge_tree", async () => { throw source; }); }
|
|
catch (error) { throw preserveWorkspaceRestoreErrorDiagnostic(wrapper, error); }
|
|
}), sink)), sink)).rejects.toBe(wrapper);
|
|
expect(getWorkspaceRestoreDiagnostic(wrapper)).toEqual({ phase: "workspace", step: "git_integration",
|
|
errorCode: "unknown", exitCode: 1, gitCommand: "merge_tree", gitFailureKind: "merge_conflict" });
|
|
expect(wrapper).not.toHaveProperty("cause");
|
|
expect(sink).toHaveBeenCalledTimes(1);
|
|
expect(sink.mock.calls[0][0]).not.toContain("private-");
|
|
});
|
|
|
|
it("keeps a nested identity probe failure instead of relabelling it as its ref transaction", async () => {
|
|
const error = Object.assign(new Error("private-probe"), { code: 128, stderr: "private-probe-output" });
|
|
await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("git_integration", () =>
|
|
withWorkspaceRestoreGitCommand("update_ref", () =>
|
|
withWorkspaceRestoreGitCommand("symbolic_ref", async () => { throw error; }))))).rejects.toBe(error);
|
|
expect(getWorkspaceRestoreDiagnostic(error)).toEqual({ phase: "workspace", step: "git_integration",
|
|
errorCode: "unknown", exitCode: 128, gitCommand: "symbolic_ref", gitFailureKind: "unknown" });
|
|
});
|
|
|
|
it("does not retain a handled command's label on a later failed step or retry", async () => {
|
|
const error = Object.assign(new Error("same error"), { code: 1 });
|
|
await expect(withWorkspaceRestoreDiagnostics("workspace", async () => {
|
|
await withWorkspaceRestoreStep("git_integration", () =>
|
|
withWorkspaceRestoreGitCommand("merge_tree", async () => { throw error; })).catch(() => {});
|
|
await withWorkspaceRestoreStep("index_reset", async () => { throw error; });
|
|
})).rejects.toBe(error);
|
|
expect(getWorkspaceRestoreDiagnostic(error)).toEqual({ phase: "workspace", step: "index_reset", errorCode: "unknown", exitCode: 1 });
|
|
await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("git_integration", async () => {
|
|
await withWorkspaceRestoreGitCommand("merge_tree", async () => { throw error; }).catch(() => {});
|
|
await withWorkspaceRestoreGitCommand("update_ref", async () => { throw error; });
|
|
}))).rejects.toBe(error);
|
|
expect(getWorkspaceRestoreDiagnostic(error)).toMatchObject({ gitCommand: "update_ref", gitFailureKind: "unknown" });
|
|
});
|
|
|
|
it("retains the selected parallel task's command when two errors share identity", async () => {
|
|
const error = Object.assign(new Error("shared"), { code: 1, stdout: "a".repeat(40) + "\n" });
|
|
const snapshots: WorkspaceRestoreDiagnostic[] = [];
|
|
await expect(withWorkspaceRestoreDiagnostics("workspace", async () => {
|
|
await Promise.allSettled((["merge_tree", "update_ref"] as const).map((command, index) =>
|
|
withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("git_integration", () =>
|
|
withWorkspaceRestoreGitCommand(command, async () => { throw error; })), undefined,
|
|
(receipt) => { snapshots[index] = receipt; })));
|
|
recordWorkspaceRestoreDiagnostic(error, snapshots[0]);
|
|
throw error;
|
|
})).rejects.toBe(error);
|
|
expect(snapshots.map(value => value.gitCommand)).toEqual(["merge_tree", "update_ref"]);
|
|
expect(getWorkspaceRestoreDiagnostic(error)).toMatchObject({ gitCommand: "merge_tree", gitFailureKind: "merge_conflict" });
|
|
});
|
|
|
|
it("leaves successful or handled commands silent and preserves values", async () => {
|
|
const sink = vi.fn();
|
|
const value = {};
|
|
expect(await withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("git_integration", async () => {
|
|
await withWorkspaceRestoreGitCommand("symbolic_ref", async () => { throw { code: 1 }; }).catch(() => {});
|
|
return withWorkspaceRestoreGitCommand("update_ref", async () => value);
|
|
}), sink)).toBe(value);
|
|
expect(sink).not.toHaveBeenCalled();
|
|
const error = new Error("outside restore");
|
|
await expect(withWorkspaceRestoreGitCommand("log", async () => { throw error; })).rejects.toBe(error);
|
|
expect(getWorkspaceRestoreDiagnostic(error)).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
{ phase: "asset", step: "git_integration", gitCommand: "merge_tree" },
|
|
{ phase: "workspace", step: "index_reset", gitCommand: "merge_tree" },
|
|
{ phase: "workspace", step: "git_integration", gitCommand: "private-command" },
|
|
])("does not decode Git metadata outside the closed integration contract (%j)", (value) => {
|
|
const diagnostic = sanitizeWorkspaceRestoreDiagnostic({ ...value, gitFailureKind: "merge_conflict" });
|
|
expect(diagnostic).not.toHaveProperty("gitCommand");
|
|
expect(diagnostic).not.toHaveProperty("gitFailureKind");
|
|
});
|
|
|
|
it("revalidates persisted command and failure labels without copying extra fields", () => {
|
|
expect(sanitizeWorkspaceRestoreDiagnostic({ phase: "workspace", step: "git_integration", gitCommand: "log",
|
|
gitFailureKind: "private-reason", stderr: "private-text", args: ["private-args"] })).toEqual({
|
|
phase: "workspace", step: "git_integration", errorCode: "unknown", gitCommand: "log", gitFailureKind: "unknown",
|
|
});
|
|
});
|
|
});
|
|
|
|
|
|
it("isolates wrapped transfer evidence across settlements reusing a frozen error", async () => {
|
|
const shared = Object.freeze(new Error("private-message"));
|
|
let resume!: () => void;
|
|
const gate = new Promise<void>(resolve => { resume = resolve; });
|
|
let started!: () => void;
|
|
const ready = new Promise<void>(resolve => { started = resolve; });
|
|
const first = withWorkspaceRestoreDiagnosticCapture(async () => {
|
|
await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("workspace_transfer", async () => {
|
|
preserveWorkspaceRestoreErrorDiagnostic(shared, {}, { transferStep: "archive_create" });
|
|
started(); await gate; throw shared;
|
|
}))).rejects.toBe(shared);
|
|
return getWorkspaceRestoreDiagnostic(shared);
|
|
});
|
|
await ready;
|
|
const second = await withWorkspaceRestoreDiagnosticCapture(async () => {
|
|
await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("workspace_transfer", async () => {
|
|
throw preserveWorkspaceRestoreErrorDiagnostic(shared, {}, { transferStep: "archive_download" });
|
|
}))).rejects.toBe(shared);
|
|
return getWorkspaceRestoreDiagnostic(shared);
|
|
});
|
|
resume();
|
|
expect((await first)?.transferStep).toBe("archive_create");
|
|
expect(second?.transferStep).toBe("archive_download");
|
|
await withWorkspaceRestoreDiagnosticCapture(async () => {
|
|
await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("workspace_transfer", async () => { throw shared; }))).rejects.toBe(shared);
|
|
expect(getWorkspaceRestoreDiagnostic(shared)?.transferStep).toBeUndefined();
|
|
});
|
|
});
|