mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The Daytona sandbox provider copies the agent's workspace back to
the host after each turn (syncOut)
> - When the sandbox `tar` fails in the `archive_create` step, the
restore diagnostic records only `exitCode: 2` and `errorCode: "unknown"`
> - The command output is not sent across the plugin RPC boundary, so an
operator cannot tell a full disk from a permission error or a path that
is too long
> - This pull request maps the system error text at the end of a failed
sandbox command's output line to the existing allowlisted `errorCode`
> - The benefit is that the next failure records the real errno, and the
output text stays private
## Linked Issues or Issue Description
Refs #18172
Refs #18212
**What happened?**
A hosted Daytona run failed its workspace restore. The `archive_create`
step exited 2 on all three attempts. The run log recorded
`{"step":"workspace_transfer","transferStep":"archive_create","transferFailureKind":"command_failed","exitCode":2,"errorCode":"unknown"}`.
Two causes are possible. The sandbox disk was full, or a path was longer
than PATH_MAX, for example from deeply nested GitHub helper config
directories before #18212. The diagnostic cannot tell these causes
apart.
**Expected behavior**
A failed sandbox command records a bounded errno such as `ENOSPC`,
`EACCES` or `ENAMETOOLONG` when its output shows one. The output text
itself must not be recorded.
**Steps to reproduce**
1. Run `performSyncOut` against a sandbox whose `tar -c` exits 2 with
`tar: ./x: Cannot open: Permission denied`.
2. Read the environment sync diagnostic from the thrown error.
3. See that `errorCode` is `unknown`.
**Paperclip version or commit**
`master` at 3fca8a1.
## What Changed
- `packages/plugins/sandbox-providers/daytona/src/file-sync.ts`:
`assertSandboxCommandOkWithOutput` maps the strerror suffix of a failed
command's output to an allowlisted errno. It reads only the last 16 KiB
in memory. A line must end with `: <message>`. Fatal causes are checked
first (ENOSPC, EIO, ENAMETOOLONG, EACCES, EPERM, ENOTDIR, EISDIR,
ENOENT), so a warning about a vanished file does not hide the error that
stopped `tar`. The error message, capacity fallback (#18172), restore
and retain-on-failure behavior do not change.
- `packages/plugins/sdk/src/environment-sync-error.ts` and
`packages/adapter-utils/src/workspace-restore-diagnostics.ts`: add
`ENAMETOOLONG` to both allowlists. Both sides still validate
independently.
- Tests: 7 new Daytona cases, plus one allowlist case each in the SDK
and adapter-utils.
## Verification
- `vitest run --config
packages/plugins/sandbox-providers/daytona/vitest.config.ts
packages/plugins/sandbox-providers/daytona/src/file-sync-diagnostics.test.ts`:
18 passed. Without the `file-sync.ts` change, the 7 new cases fail with
`errorCode: "unknown"`.
- `packages/plugins/sdk`: `vitest run
tests/environment-sync-error.test.ts`: 20 passed.
- `packages/adapter-utils`: `vitest run
src/workspace-restore-diagnostics.test.ts`: 50 passed.
- `tsc --noEmit` passes for the SDK and adapter-utils. `git diff
--check` is clean.
- I did not run the Daytona package typecheck or its full suite locally
because the sandbox disk was full. CI covers them.
## Risks
- Low risk. The change adds a diagnostic field only. Control flow, error
messages and the capacity fallback regex do not change.
- Localized `tar` output does not match, so it stays `unknown`. That is
the current behavior.
- `ENAMETOOLONG` is a new allowlisted code on both sides. It is a fixed
string and carries no path or output text.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in an agent harness.
## 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
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
337 lines
20 KiB
TypeScript
337 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("keeps an allowlisted ENAMETOOLONG code", async () => {
|
|
const sink = vi.fn();
|
|
const error = Object.assign(new Error("private"), { code: "ENAMETOOLONG" });
|
|
await expect(withWorkspaceRestoreDiagnostics("workspace", async () => { throw error; }, sink)).rejects.toBe(error);
|
|
expect(sink.mock.calls[0]?.[0]).toContain('"errorCode":"ENAMETOOLONG"');
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|