mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Remote workspace imports preserve existing files before the server changes workspace authority. > - Archived Git imports reconstruct saved files and Git state in a private repository. > - A failed upload can leave a pending snapshot journal with no object in storage. > - A later reconstruction can have different Git administrative bytes for the same saved logical source. > - This pull request lets that import record a new unpublished capture after it proves that prior objects are absent. > - The benefit is safe retry through the original import journal while source checks and atomic publication remain in place. ## Linked Issues or Issue Description Refs #19396. Refs #19409. A snapshot upload can fail before object persistence. The caller then removes its private archive. A retry reconstructs the same saved Git workspace. If its administrative bytes differ, the snapshot owner rejects the retry with `remote_workspace_snapshot_operation_content_changed`. The pending journal remains blocked even though no snapshot object or remote authority was published. ## What Changed - Add an optional unpublished recapture capability to snapshot storage. Bind it to the original source operation. Existing callers keep the exact manifest check. - Enable this capability only for archived Git reconstruction. The migration owner keeps the source recipe, task binding, ownership, and publication checks. - Verify every prior object before new capture admission. Unknown or corrupt objects stop the retry. Reuse an available prior object, including an object that appears while the replacement upload runs. - Compare the observed upload journal under the snapshot row lock before appending an attempt. Keep network I/O outside that lock. Preserve all prior attempt metadata. - Keep the first atomic publication as the winner. A losing uploader verifies and returns that saved reference. - Add real PostgreSQL, filesystem, and Git regression tests. Document the retry behavior. ## Verification - Baseline `70d23612dd9176cae8ee6b2b6dcf2f6eade9a42a`: the new real Git fixture fails with `remote_workspace_snapshot_operation_content_changed`. It fails the first upload before persistence, removes the old source cache, and advances unrelated remote refs. The saved task commit and restore point stay fixed. - Final frozen source: 20 tests pass in a fresh process. This includes the real Git regression and all snapshot storage cases. Tests cover prior object recovery, corrupt and unknown objects, normal caller mismatch rejection, operation binding, stale journal admission, and late upload publication in both orders. - Reproduce the final focused check from `server/`: ```sh node ../node_modules/vitest/vitest.mjs run --config vitest.config.ts src/services/remote-workspace-snapshot-storage.test.ts src/services/remote-workspace-migration.test.ts -t 'failed-upload-retry|snapshot storage' --reporter=dot ``` - An earlier six-suite run had 201 passing cases and one failing late-object case. That run started before the final prior-object recheck was added. The failing case passes in the fresh final run above. This earlier run is not a clean full-family result for the final source. - That run skipped 16 lifecycle cases because `PAPERCLIP_REMOTE_WORKSPACE_LIFECYCLE_TEST_DATABASE_URL` was unset. Migration, storage, reservation, and concurrency tests used real disposable embedded PostgreSQL. - `git diff --check` passes. Full repository typecheck passed before rebasing. Full test, build, and current-head CI results are pending. ## Risks - The optional capability permits different physical captures. The migration owner must prove that the logical source and current ownership are unchanged. Ordinary mutable workspace uploads keep the previous mismatch guard. - An old upload can complete after the last object check. The first authenticated atomic publication wins. A late upload cannot replace its object or snapshot reference. Every candidate remains in the retention journal. - Unknown storage results and journal changes fail closed. They can require another canonical retry. This change adds no retry loop and changes no database schema. ## Model Used OpenAI Codex, GPT-6 family. The worker used reasoning, tool use, code editing, and local test execution. The worker runtime did not expose an exact model ID or context window. ## 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 - [ ] 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>
290 lines
20 KiB
TypeScript
290 lines
20 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { Readable } from "node:stream";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { applyPendingMigrations, closeRegisteredClients, createDb, executionWorkspaceSnapshots } from "@paperclipai/db";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
|
|
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
|
|
import { createStorageService } from "../storage/service.js";
|
|
import type { StorageService } from "../storage/types.js";
|
|
import { captureRemoteWorkspaceDirectory, restoreRemoteWorkspaceArchive } from "./remote-workspace-snapshot-archive.js";
|
|
import { beginWorkspaceSnapshot, getWorkspaceSnapshot, loadWorkspaceSnapshotArchive, recoverWorkspaceSnapshotArchive, uploadWorkspaceSnapshot } from "./remote-workspace-snapshot-storage.js";
|
|
|
|
const externalDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL;
|
|
if (externalDatabaseUrl && !/^remote_workspace_[a-z_]*test$/.test(new URL(externalDatabaseUrl).pathname.slice(1))) {
|
|
throw new Error("Workspace storage tests require a disposable remote_workspace_*test database");
|
|
}
|
|
const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
|
const suite = support.supported ? describe : describe.skip;
|
|
if (!support.supported) console.warn(`Remote workspace snapshot DB tests unavailable: ${support.reason}`);
|
|
suite("remote workspace snapshot storage", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>, db: ReturnType<typeof createDb>;
|
|
let root: string, storage: StorageService;
|
|
beforeAll(async () => {
|
|
if (externalDatabaseUrl) { await applyPendingMigrations(externalDatabaseUrl); db = createDb(externalDatabaseUrl); }
|
|
else { database = await startEmbeddedPostgresTestDatabase("workspace-snapshot-"); db = createDb(database.connectionString); }
|
|
}, 90_000);
|
|
afterAll(async () => { if (externalDatabaseUrl) await closeRegisteredClients(externalDatabaseUrl); else await database?.cleanup(); });
|
|
beforeEach(async () => {
|
|
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "41".repeat(32));
|
|
root = await fs.mkdtemp(path.join(os.tmpdir(), "pc-workspace-store-test-"));
|
|
storage = createStorageService(createLocalDiskStorageProvider(path.join(root, "objects")));
|
|
});
|
|
afterEach(async () => { vi.unstubAllEnvs(); await db.delete(executionWorkspaceSnapshots); await fs.rm(root, { recursive: true, force: true }); });
|
|
async function fixture() {
|
|
const companyId = randomUUID(), workspaceId = randomUUID(), operationId = randomUUID();
|
|
const cwd = path.join(root, "source"), archivePath = path.join(root, "archive.pcws"); await fs.mkdir(cwd);
|
|
await fs.writeFile(path.join(cwd, "saved.txt"), "acknowledged workspace content\n");
|
|
const captured = await captureRemoteWorkspaceDirectory({ cwd, archivePath });
|
|
const row = await beginWorkspaceSnapshot(db, { companyId, workspaceId, operationId, generation: 1, epoch: 3 });
|
|
const upload = { db, storage, snapshotId: row.id, companyId, workspaceId, archivePath, manifestDigest: captured.manifestDigest };
|
|
return { companyId, workspaceId, operationId, cwd, archivePath, captured, row, upload };
|
|
}
|
|
it("streams encrypted saved data through the real ledger and filesystem object backend", async () => {
|
|
const f = await fixture(), ref = await uploadWorkspaceSnapshot(f.upload);
|
|
expect(ref.byteSize).toBeGreaterThan(f.captured.byteSize);
|
|
const object = await storage.getObject(f.companyId, ref.objectKey); const parts: Buffer[] = [];
|
|
for await (const part of object.stream) parts.push(part);
|
|
expect(Buffer.concat(parts).includes(Buffer.from("acknowledged workspace content"))).toBe(false);
|
|
await fs.rm(f.cwd, { recursive: true }); await fs.rm(f.archivePath);
|
|
const restoredArchive = path.join(root, "restored.pcws"), targetDir = path.join(root, "restored");
|
|
await loadWorkspaceSnapshotArchive({ storage, reference: ref, targetPath: restoredArchive });
|
|
await restoreRemoteWorkspaceArchive({ archivePath: restoredArchive, targetDir, expectedManifestDigest: ref.manifestDigest });
|
|
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("acknowledged workspace content\n");
|
|
const [row] = await db.select().from(executionWorkspaceSnapshots).where(eq(executionWorkspaceSnapshots.id, ref.id));
|
|
expect(row?.status).toBe("uploaded"); // The owning service still controls atomic publication.
|
|
});
|
|
it("reconciles an upload whose reply was lost after object persistence, without the old scratch file", async () => {
|
|
const f = await fixture(); const put = storage.putFile.bind(storage); let loseReply = true;
|
|
storage.putFile = async input => { const result = await put(input); if (loseReply) { loseReply = false; throw new Error("lost upload reply"); } return result; };
|
|
await expect(uploadWorkspaceSnapshot(f.upload)).rejects.toThrow("lost upload reply");
|
|
await fs.rm(f.archivePath);
|
|
const ref = await uploadWorkspaceSnapshot(f.upload);
|
|
expect(ref.manifestDigest).toBe(f.captured.manifestDigest);
|
|
expect(await getWorkspaceSnapshot(db, { ...f, snapshotId: ref.id })).toEqual(ref);
|
|
const [row] = await db.select().from(executionWorkspaceSnapshots).where(eq(executionWorkspaceSnapshots.id, ref.id));
|
|
expect((row?.metadata?.uploads as unknown[]).length).toBe(1);
|
|
});
|
|
it("retains an unsuccessful operation and rejects a content-changing retry", async () => {
|
|
const f = await fixture(); storage.putFile = async () => { throw new Error("storage unavailable"); };
|
|
await expect(uploadWorkspaceSnapshot(f.upload)).rejects.toThrow("storage unavailable");
|
|
const [row] = await db.select().from(executionWorkspaceSnapshots).where(eq(executionWorkspaceSnapshots.id, f.row.id));
|
|
expect(row?.status).toBe("pending"); expect((row?.metadata?.uploads as unknown[]).length).toBe(1);
|
|
await expect(uploadWorkspaceSnapshot({ ...f.upload, manifestDigest: "b".repeat(64) })).rejects.toThrow("operation_content_changed");
|
|
});
|
|
it("recaptures only an unavailable unpublished operation while retaining every attempt", async () => {
|
|
const f = await fixture(), put = storage.putFile.bind(storage);
|
|
storage.putFile = async () => { throw new Error("upload_part InternalError"); };
|
|
await expect(uploadWorkspaceSnapshot(f.upload)).rejects.toThrow("upload_part InternalError");
|
|
const [pending] = await db.select().from(executionWorkspaceSnapshots);
|
|
const original = (pending!.metadata!.uploads as Record<string, unknown>[])[0]!;
|
|
await fs.rm(f.archivePath);
|
|
await fs.writeFile(path.join(f.cwd, "saved.txt"), "equivalent logical source, different physical capture\n");
|
|
const captured = await captureRemoteWorkspaceDirectory({ cwd: f.cwd, archivePath: f.archivePath });
|
|
const retry = { ...f.upload, manifestDigest: captured.manifestDigest, unpublishedRecapture: { sourceOperationId: f.operationId } };
|
|
await expect(uploadWorkspaceSnapshot({ ...retry, unpublishedRecapture: { sourceOperationId: randomUUID() } })).rejects.toThrow("operation_binding_changed");
|
|
storage.putFile = put;
|
|
const ref = await uploadWorkspaceSnapshot(retry);
|
|
expect(ref.manifestDigest).toBe(captured.manifestDigest);
|
|
const [saved] = await db.select().from(executionWorkspaceSnapshots);
|
|
expect(saved!.metadata!.uploads).toEqual([original, expect.objectContaining({ objectKey: ref.objectKey, manifestDigest: captured.manifestDigest })]);
|
|
// The capability does not reopen a published snapshot.
|
|
await expect(uploadWorkspaceSnapshot({ ...retry, manifestDigest: f.captured.manifestDigest })).rejects.toThrow("operation_content_changed");
|
|
});
|
|
it("prefers an available prior capture over a different recapture", async () => {
|
|
const f = await fixture(), put = storage.putFile.bind(storage);
|
|
storage.putFile = async input => { await put(input); throw new Error("lost upload reply"); };
|
|
await expect(uploadWorkspaceSnapshot(f.upload)).rejects.toThrow("lost upload reply");
|
|
await fs.rm(f.archivePath);
|
|
const ref = await uploadWorkspaceSnapshot({ ...f.upload, manifestDigest: "b".repeat(64), unpublishedRecapture: { sourceOperationId: f.operationId } });
|
|
expect(ref.manifestDigest).toBe(f.captured.manifestDigest);
|
|
expect((await db.select().from(executionWorkspaceSnapshots))[0]!.metadata!.uploads).toHaveLength(1);
|
|
});
|
|
it("rechecks prior objects after encryption before recording a recapture", async () => {
|
|
const f = await fixture(), put = storage.putFile.bind(storage), head = storage.headObject.bind(storage);
|
|
let savedInput!: Parameters<StorageService["putFile"]>[0];
|
|
storage.putFile = async input => {
|
|
const parts: Buffer[] = [];
|
|
for await (const bytes of input.body) parts.push(Buffer.from(bytes));
|
|
savedInput = { ...input, body: Buffer.concat(parts) };
|
|
throw new Error("upload interrupted before persistence");
|
|
};
|
|
await expect(uploadWorkspaceSnapshot(f.upload)).rejects.toThrow("upload interrupted before persistence");
|
|
let heads = 0;
|
|
storage.headObject = async (...args) => { if (++heads === 2) await put(savedInput); return head(...args); };
|
|
storage.putFile = put;
|
|
const ref = await uploadWorkspaceSnapshot({ ...f.upload, manifestDigest: "b".repeat(64), unpublishedRecapture: { sourceOperationId: f.operationId } });
|
|
expect(ref.manifestDigest).toBe(f.captured.manifestDigest);
|
|
expect(heads).toBe(2);
|
|
expect((await db.select().from(executionWorkspaceSnapshots))[0]!.metadata!.uploads).toHaveLength(1);
|
|
});
|
|
it("refuses stale recapture admission when another attempt enters the ledger", async () => {
|
|
const f = await fixture(), head = storage.headObject.bind(storage);
|
|
storage.putFile = async () => { throw new Error("upload unavailable"); };
|
|
await expect(uploadWorkspaceSnapshot(f.upload)).rejects.toThrow("upload unavailable");
|
|
let ready!: () => void, release!: () => void;
|
|
const paused = new Promise<void>(resolve => { ready = resolve; }), resume = new Promise<void>(resolve => { release = resolve; });
|
|
let heads = 0;
|
|
storage.headObject = async (...args) => { if (++heads === 1) { ready(); await resume; } return head(...args); };
|
|
const stale = uploadWorkspaceSnapshot({ ...f.upload, manifestDigest: "b".repeat(64), unpublishedRecapture: { sourceOperationId: f.operationId } });
|
|
try {
|
|
await paused;
|
|
await expect(uploadWorkspaceSnapshot(f.upload)).rejects.toThrow("upload unavailable");
|
|
release();
|
|
await expect(stale).rejects.toThrow("upload_attempts_changed");
|
|
const [saved] = await db.select().from(executionWorkspaceSnapshots);
|
|
expect(saved).toMatchObject({ status: "pending", objectKey: null });
|
|
expect(saved!.metadata!.uploads).toHaveLength(2);
|
|
} finally { release(); await Promise.allSettled([stale]); }
|
|
});
|
|
it.each(["unknown", "corrupt"])("refuses recapture when a prior object is %s", async state => {
|
|
const f = await fixture(), put = storage.putFile.bind(storage);
|
|
storage.putFile = async input => { await put(input); throw new Error("lost upload reply"); };
|
|
await expect(uploadWorkspaceSnapshot(f.upload)).rejects.toThrow("lost upload reply");
|
|
if (state === "unknown") storage.headObject = async () => { throw new Error("HEAD unavailable"); };
|
|
else {
|
|
const [pending] = await db.select().from(executionWorkspaceSnapshots);
|
|
const [attempt] = pending!.metadata!.uploads as { objectKey: string }[];
|
|
await fs.writeFile(path.join(root, "objects", attempt!.objectKey), "corrupt object");
|
|
}
|
|
await expect(uploadWorkspaceSnapshot({ ...f.upload, manifestDigest: "b".repeat(64), unpublishedRecapture: { sourceOperationId: f.operationId } }))
|
|
.rejects.toThrow(state === "unknown" ? "HEAD unavailable" : "object_hash_mismatch");
|
|
const [saved] = await db.select().from(executionWorkspaceSnapshots);
|
|
expect(saved).toMatchObject({ status: "pending", objectKey: null });
|
|
expect(saved!.metadata!.uploads).toHaveLength(1);
|
|
});
|
|
it.each(["original", "recapture", "original-with-lost-reply"])("keeps a %s winner when another physical capture finishes late", async winner => {
|
|
const f = await fixture(), put = storage.putFile.bind(storage);
|
|
const deferred = () => {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>(ready => { resolve = ready; });
|
|
return { resolve, promise };
|
|
};
|
|
const originalReady = deferred(), originalRelease = deferred(), recaptureReady = deferred(), recaptureRelease = deferred();
|
|
let calls = 0;
|
|
storage.putFile = async input => {
|
|
const first = ++calls === 1;
|
|
if (first) { originalReady.resolve(); await originalRelease.promise; }
|
|
else { recaptureReady.resolve(); await recaptureRelease.promise; }
|
|
const saved = await put(input);
|
|
if (first && winner === "original-with-lost-reply") throw new Error("late original upload reply lost");
|
|
return saved;
|
|
};
|
|
const unpublishedRecapture = { sourceOperationId: f.operationId };
|
|
const original = uploadWorkspaceSnapshot({ ...f.upload, unpublishedRecapture });
|
|
let recapture: ReturnType<typeof uploadWorkspaceSnapshot> | undefined;
|
|
try {
|
|
await originalReady.promise;
|
|
const archivePath = path.join(root, "recaptured.pcws");
|
|
await fs.writeFile(path.join(f.cwd, "saved.txt"), "different physical capture\n");
|
|
const captured = await captureRemoteWorkspaceDirectory({ cwd: f.cwd, archivePath });
|
|
recapture = uploadWorkspaceSnapshot({ ...f.upload, archivePath, manifestDigest: captured.manifestDigest, unpublishedRecapture });
|
|
await recaptureReady.promise;
|
|
(winner === "recapture" ? recaptureRelease : originalRelease).resolve();
|
|
if (winner === "original-with-lost-reply") {
|
|
await expect(original).rejects.toThrow("late original upload reply lost");
|
|
recaptureRelease.resolve();
|
|
}
|
|
const saved = await (winner === "original" ? original : recapture);
|
|
expect(saved.manifestDigest).toBe(winner === "recapture" ? captured.manifestDigest : f.captured.manifestDigest);
|
|
originalRelease.resolve(); recaptureRelease.resolve();
|
|
if (winner !== "original-with-lost-reply") expect(await original).toEqual(saved);
|
|
expect(await recapture).toEqual(saved);
|
|
expect(await getWorkspaceSnapshot(db, { ...f, snapshotId: f.row.id })).toEqual(saved);
|
|
expect((await db.select().from(executionWorkspaceSnapshots))[0]!.metadata!.uploads).toHaveLength(2);
|
|
} finally { originalRelease.resolve(); recaptureRelease.resolve(); await Promise.allSettled([original, recapture]); }
|
|
});
|
|
it("authenticates a lost-reply archive without publishing its pending upload", async () => {
|
|
const f = await fixture(), put = storage.putFile.bind(storage);
|
|
storage.putFile = async input => { await put(input); throw new Error("lost upload reply"); };
|
|
await expect(uploadWorkspaceSnapshot(f.upload)).rejects.toThrow("lost upload reply");
|
|
await fs.rm(f.archivePath);
|
|
const targetPath = path.join(root, "recovered.pcws");
|
|
const ref = await recoverWorkspaceSnapshotArchive({ ...f.upload, targetPath });
|
|
expect(ref?.manifestDigest).toBe(f.captured.manifestDigest);
|
|
expect((await db.select().from(executionWorkspaceSnapshots))[0]?.status).toBe("pending");
|
|
expect(await getWorkspaceSnapshot(db, { ...f, snapshotId: f.row.id })).toBeNull();
|
|
await restoreRemoteWorkspaceArchive({ archivePath: targetPath, targetDir: path.join(root, "restored"), expectedManifestDigest: ref!.manifestDigest });
|
|
expect(await fs.readFile(path.join(root, "restored", "saved.txt"), "utf8")).toBe("acknowledged workspace content\n");
|
|
await expect(recoverWorkspaceSnapshotArchive({ ...f.upload, companyId: randomUUID(), targetPath: path.join(root, "wrong-company.pcws") }))
|
|
.rejects.toThrow("remote_workspace_snapshot_missing");
|
|
});
|
|
it("keeps operation identity and company/workspace scope exact", async () => {
|
|
const f = await fixture();
|
|
expect((await beginWorkspaceSnapshot(db, { ...f, generation: 1, epoch: 3 })).id).toBe(f.row.id);
|
|
await expect(beginWorkspaceSnapshot(db, { ...f, generation: 1, epoch: 4 })).rejects.toThrow("operation_binding_changed");
|
|
await uploadWorkspaceSnapshot(f.upload);
|
|
expect(await getWorkspaceSnapshot(db, { ...f, snapshotId: f.row.id, companyId: randomUUID() })).toBeNull();
|
|
expect(await getWorkspaceSnapshot(db, { ...f, snapshotId: f.row.id, workspaceId: randomUUID() })).toBeNull();
|
|
});
|
|
it("rejects object corruption and leaves the requested plaintext path unpublished", async () => {
|
|
const f = await fixture(), ref = await uploadWorkspaceSnapshot(f.upload);
|
|
const objectPath = path.join(root, "objects", ref.objectKey), object = await fs.open(objectPath, "r+");
|
|
try {
|
|
const byte = Buffer.alloc(1);
|
|
expect((await object.read(byte, 0, 1, 15)).bytesRead).toBe(1);
|
|
byte[0] = byte[0]! ^ 1;
|
|
await object.write(byte, 0, 1, 15);
|
|
} finally { await object.close(); }
|
|
const targetPath = path.join(root, "corrupted.pcws");
|
|
await expect(loadWorkspaceSnapshotArchive({ storage, reference: ref, targetPath })).rejects.toThrow("object_hash_mismatch");
|
|
await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
it("rejects a foreign wrapped key even when ciphertext metadata is unchanged", async () => {
|
|
const f = await fixture(), ref = await uploadWorkspaceSnapshot(f.upload);
|
|
const foreignId = randomUUID();
|
|
await expect(loadWorkspaceSnapshotArchive({ storage, reference: { ...ref, id: foreignId, objectKey: ref.objectKey.replace(ref.id, foreignId) },
|
|
targetPath: path.join(root, "foreign.pcws") })).rejects.toThrow("key_binding_invalid");
|
|
});
|
|
it("does not overwrite an existing caller file", async () => {
|
|
const f = await fixture(), ref = await uploadWorkspaceSnapshot(f.upload), targetPath = path.join(root, "existing.pcws");
|
|
await fs.writeFile(targetPath, "existing");
|
|
await expect(loadWorkspaceSnapshotArchive({ storage, reference: ref, targetPath })).rejects.toMatchObject({ code: "EEXIST" });
|
|
expect(await fs.readFile(targetPath, "utf8")).toBe("existing");
|
|
});
|
|
it("does not start object retrieval for an already canceled snapshot load", async () => {
|
|
const f = await fixture(), ref = await uploadWorkspaceSnapshot(f.upload);
|
|
const getObject = vi.spyOn(storage, "getObject");
|
|
const controller = new AbortController(), reason = new Error("request already canceled");
|
|
controller.abort(reason);
|
|
const targetPath = path.join(root, "canceled.pcws");
|
|
const error = await loadWorkspaceSnapshotArchive({ storage, reference: ref, targetPath, signal: controller.signal }).catch(error => error);
|
|
expect(getObject).not.toHaveBeenCalled();
|
|
expect(error).toBe(reason);
|
|
await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
it("cancels an active object body and removes private decryption scratch before rejecting", async () => {
|
|
const f = await fixture(), ref = await uploadWorkspaceSnapshot(f.upload);
|
|
const ciphertext = await fs.readFile(path.join(root, "objects", ref.objectKey));
|
|
const controller = new AbortController();
|
|
let produced = 0, closed = false;
|
|
const body = new Readable({
|
|
highWaterMark: 1,
|
|
read() {
|
|
if (produced === 2) { controller.abort(new Error("client disconnected")); return; }
|
|
this.push(ciphertext.subarray(produced, ++produced));
|
|
},
|
|
destroy(error, callback) { closed = true; callback(error); },
|
|
});
|
|
storage.getObject = vi.fn(async (_companyId, _objectKey, options) => {
|
|
expect(options?.signal).toBe(controller.signal);
|
|
return { stream: body, contentLength: ciphertext.length };
|
|
});
|
|
const scratchRoot = path.join(root, "private-scratch"); await fs.mkdir(scratchRoot);
|
|
const temp = vi.spyOn(os, "tmpdir").mockReturnValue(scratchRoot);
|
|
const targetPath = path.join(root, "interrupted.pcws");
|
|
try {
|
|
await expect(loadWorkspaceSnapshotArchive({ storage, reference: ref, targetPath, signal: controller.signal }))
|
|
.rejects.toMatchObject({ name: "AbortError" });
|
|
expect(closed).toBe(true); expect(body.destroyed).toBe(true);
|
|
expect(produced).toBeGreaterThan(0); expect(produced).toBeLessThan(ciphertext.length);
|
|
const atRejection = produced; await new Promise(resolve => setImmediate(resolve)); expect(produced).toBe(atRejection);
|
|
expect(await fs.readdir(scratchRoot)).toEqual([]);
|
|
await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
} finally { temp.mockRestore(); }
|
|
});
|
|
});
|