Files
PaperClipAI/server/src/services/remote-workspace-nested-verification.test.ts
T
DottaandPaperclip bf0220d34a perf(workspaces): migrate with one archive and no readback (#20554)
## Thinking Path

> - Paperclip coordinates agents and keeps their workspace files.
> - A legacy workspace must move from a local volume to object storage.
> - The importer creates an archive, downloads and restores it, and
captures the source again.
> - Parent imports also download and recapture children that already
have committed snapshots.
> - This repeated work makes the one-time migration slow.
> - This change copies each source file once and trusts the upload
acknowledgement.
> - Existing source locks and atomic publication still own the
migration.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The one-time migration of legacy local and Git workspaces to durable
snapshots.

**Current behavior**

Each workspace is captured twice and restored as a test during
migration. Parent imports also read back already imported child
snapshots. Capture hashes source files before writing them and scans
them again afterward.

**Proposed behavior**

Build one archive from one source-file read, encrypt it, upload it, and
publish its reference. Trust the upload acknowledgement. Reuse journaled
uploads after an interrupted process. Retain committed child snapshots
by database reference.

**Reason and benefit**

Remove repeated source reads, temporary copies, complete downloads, and
recursive child verification. The tests assert one capture, one upload,
and zero migration downloads.

**Breaking changes**

The archive format stays compatible. File hashes required by current
readers are computed inline during the copy. There is no preliminary
file-hashing pass. Existing readers can cold-restore the result.

Related work: #19817 and #20447.

## What Changed

- Add single-pass capture that fills fixed-length checksum fields in the
existing archive header after copying.
- Remove migration readback, test extraction, fresh-source recapture,
and Git integrity scans.
- Compute the encrypted archive checksum while writing it.
- Reuse acknowledged and journaled uploads without rebuilding or
downloading them.
- Retain already imported children through their database bindings.
Remove the recursive payload verification cache.
- Keep company checks, source and writer locks, recovery bindings,
journal identities, cancellation, and atomic publication.
- Bind archived repository intent paths to the operation without
reconstructing an archive to check them.
- Update the migration contract and the affected tests.

## Verification

- Node 24.22.2 and pnpm 9.15.4 on macOS arm64.
- At e876dbdc: focused capture, migration, lost-acknowledgement, and
frozen old-reader compatibility checks: 11 passed.
- Real source-file open counts prove one read per file. The single-pass
archive matches the existing archive bytes and manifest.
- Cold restore preserves staged and unstaged Git work, untracked files,
executable modes, and confined symlinks.
- At e876dbdc: full archive and cohort suites: 147 passed.
- At e876dbdc: server layered typecheck passed.
- At 58e5c229: four review fixes add Git source-root confinement,
archived lost-acknowledgement reuse, old-journal intent binding, and
wrapped-key readability. New focused regressions are included;
current-revision CI is pending.
- Post-review local runs were inconclusive: Git cases timed out in both
existing strict and single-pass modes, and DB tests stalled before
assertions. The unchanged earlier passes are not presented as
final-revision passes. Required CI will verify the final revision.
- Earlier full migration, snapshot storage, and nested-reference suites
passed before the compatibility refinement. Required CI supplies fresh
broad coverage for the final revision.
- Initial compatibility checks exposed the need to retain the existing
file-hash header. Inline checksums fixed that failure without another
source-file pass.
- Initial tests that required repeated verification were updated to the
requested copy-once contract. Upload-time database mutations still test
the retained business-state guards.

## Risks

- Migration now trusts the storage acknowledgement and cooperating
writer fences. It does not prove the uploaded bytes with a download or
detect later local edits with a second capture.
- Already imported child objects are trusted without another object
read. Their database bindings remain checked.
- Original source files remain available for recovery.
- This adds no automatic migration, server pin, or serving deployment.
Ordinary run capture and authenticated restore retain their existing
behavior.

## Model Used

OpenAI Codex, GPT-6 family, with code editing and terminal tools. The
exact runtime model revision and context window 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, including the runtime details
available to this session
- [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
related work above
- [x] I have described the issue in-PR following the enhancement issue
template
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
ticket ID
- [x] I have run relevant tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation
- [x] I have considered and documented risks
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open follow-ups
- [x] I will address reviewer comments before requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 09:13:33 -06:00

135 lines
11 KiB
TypeScript

import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { activityLog, companies, createDb, executionWorkspaceSnapshots, executionWorkspaces, issues, projects, workspaceOperations } from "@paperclipai/db";
import { readRemoteWorkspaceAuthority } from "@paperclipai/shared";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
import { createStorageService } from "../storage/service.js";
import type { StorageService } from "../storage/types.js";
import { migrateRemoteWorkspace } from "./remote-workspace-migration.js";
import * as workspaceArchive from "./remote-workspace-snapshot-archive.js";
import { createNestedWorkspaceImportVerifier } from "./remote-workspace-migration-tree.js";
import { withWorkspaceSourceLocks } from "./workspace-source-lock.js";
const support = await getEmbeddedPostgresTestSupport(), execute = promisify(execFile);
const git = async (cwd: string, ...args: string[]) => (await execute("git", ["-c", `core.hooksPath=${os.devNull}`, "-C", cwd, ...args], {
env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull, GIT_TERMINAL_PROMPT: "0",
GIT_AUTHOR_NAME: "Nested import", GIT_AUTHOR_EMAIL: "fixture@example.test", GIT_COMMITTER_NAME: "Nested import", GIT_COMMITTER_EMAIL: "fixture@example.test" },
})).stdout.trim();
describe.skipIf(!support.supported)("nested workspace migration references", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>, db: ReturnType<typeof createDb>, home: string, storage: StorageService;
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("nested-verification-"); db = createDb(database.connectionString, { maxConnections: 8 }); }, 90_000);
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await database?.cleanup(); });
beforeEach(async () => {
home = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "pc-nested-verification-")));
vi.stubEnv("PAPERCLIP_HOME", home); vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "61".repeat(32));
storage = createStorageService(createLocalDiskStorageProvider(path.join(home, "objects")));
});
afterEach(async () => {
vi.restoreAllMocks(); await db.delete(activityLog); await db.delete(workspaceOperations); await db.delete(executionWorkspaceSnapshots);
await db.delete(issues); await db.delete(executionWorkspaces); await db.delete(projects); await db.delete(companies);
vi.unstubAllEnvs(); await fs.rm(home, { recursive: true, force: true });
});
async function fixture() {
const companyId = randomUUID(), projectId = randomUUID(), root = resolveManagedProjectWorkspaceDir({ companyId, projectId });
await db.insert(companies).values({ id: companyId, name: "Cohort", issuePrefix: `C${companyId.slice(0, 6)}` });
await db.insert(projects).values({ id: projectId, companyId, name: "Shared Git" });
await fs.mkdir(root, { recursive: true }); await fs.writeFile(path.join(root, "saved.txt"), "committed base\n");
await git(root, "init", "-qb", "main"); await git(root, "add", "."); await git(root, "commit", "-qm", "Original history");
const repoUrl = "https://example.invalid/cohort.git"; await git(root, "remote", "add", "origin", repoUrl);
const members = [];
for (const name of ["base", "one", "two"]) {
const cwd = name === "base" ? root : path.join(root, ".paperclip", "worktrees", name);
if (name !== "base") await git(root, "worktree", "add", "-qb", name, cwd);
const [issue] = await db.insert(issues).values({ companyId, projectId, title: name }).returning();
const [workspace] = await db.insert(executionWorkspaces).values({ companyId, projectId, sourceIssueId: issue.id, name,
mode: name === "base" ? "shared_workspace" : "isolated_workspace", strategyType: name === "base" ? "project_primary" : "git_worktree",
providerType: name === "base" ? "local_fs" : "git_worktree", cwd, providerRef: cwd, repoUrl, branchName: name === "base" ? "main" : name }).returning();
await db.update(issues).set({ executionWorkspaceId: workspace.id }).where(eq(issues.id, issue.id));
if (name !== "base") { await fs.writeFile(path.join(cwd, "saved.txt"), `${name} dirty tracked work\n`); await fs.writeFile(path.join(cwd, "untracked.txt"), `${name} untracked work\n`); }
members.push(workspace);
}
return { companyId, projectId, root, parent: members[0], leaves: members.slice(1) };
}
async function imported(grandchildren = false) {
const f = await fixture();
const descendants = [];
if (grandchildren) for (const child of f.leaves) {
const cwd = path.join(child.cwd!, ".paperclip", "worktrees", "nested"), name = `nested-${child.name}`;
await git(f.root, "worktree", "add", "-qb", name, cwd);
const [issue] = await db.insert(issues).values({ companyId: f.companyId, projectId: f.projectId, title: name }).returning();
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: f.projectId,
sourceIssueId: issue.id, name, mode: "isolated_workspace", strategyType: "git_worktree", providerType: "git_worktree",
cwd, providerRef: cwd, repoUrl: child.repoUrl, branchName: name }).returning();
await db.update(issues).set({ executionWorkspaceId: workspace.id }).where(eq(issues.id, issue.id));
await fs.writeFile(path.join(cwd, "untracked.txt"), name); descendants.push(workspace);
}
for (const workspace of descendants) {
await migrateRemoteWorkspace({ db, storage, companyId: f.companyId, workspaceId: workspace.id, apply: true,
signal: AbortSignal.timeout(50_000) });
}
for (const workspace of f.leaves) await migrateRemoteWorkspace({ db, storage, companyId: f.companyId, workspaceId: workspace.id,
apply: true, signal: AbortSignal.timeout(50_000) });
const snapshots = await db.select().from(executionWorkspaceSnapshots);
return { ...f, snapshots, descendants, keys: new Set(snapshots.map(snapshot => snapshot.objectKey!)) };
}
const parent = (f: Awaited<ReturnType<typeof imported>>) =>
migrateRemoteWorkspace({ db, storage, companyId: f.companyId, workspaceId: f.parent.id, apply: true, signal: AbortSignal.timeout(20_000) });
it("retains transitive committed children without downloading, restoring, or recapturing them", async () => {
const f = await imported(true), get = vi.spyOn(storage, "getObject"), head = vi.spyOn(storage, "headObject");
const capture = vi.spyOn(workspaceArchive, "captureRemoteWorkspaceDirectory"), restore = vi.spyOn(workspaceArchive, "restoreRemoteWorkspaceArchive");
expect(await parent(f)).toMatchObject({ applied: true });
expect(get).not.toHaveBeenCalled(); expect(head).not.toHaveBeenCalled(); expect(restore).not.toHaveBeenCalled();
expect(capture).toHaveBeenCalledTimes(1);
const [snapshot] = await db.select().from(executionWorkspaceSnapshots).where(eq(executionWorkspaceSnapshots.executionWorkspaceId, f.parent.id));
expect(snapshot.metadata?.importedWorkspaceChildren).toHaveLength(2);
expect((snapshot.metadata!.importedWorkspaceChildren as Array<{ workspaceId: string }>).map(child => child.workspaceId).sort())
.toEqual(f.leaves.map(child => child.id).sort());
expect((await db.select().from(executionWorkspaceSnapshots)).every(row => !row.metadata?.nestedImportVerification)).toBe(true);
for (const descendant of f.descendants) expect(await fs.readFile(path.join(descendant.cwd!, "untracked.txt"), "utf8")).toBe(descendant.name);
});
it.each(["local edit", "missing object"])("trusts a committed child's recorded upload after a %s", async mode => {
const f = await imported();
if (mode === "local edit") await fs.writeFile(path.join(f.leaves[0].cwd!, "untracked.txt"), "later local bytes\n");
else await storage.deleteObject(f.companyId, f.snapshots[0].objectKey!);
const get = vi.spyOn(storage, "getObject"), head = vi.spyOn(storage, "headObject");
expect(await parent(f)).toMatchObject({ applied: true });
expect(get).not.toHaveBeenCalled(); expect(head).not.toHaveBeenCalled();
const [snapshot] = await db.select().from(executionWorkspaceSnapshots).where(eq(executionWorkspaceSnapshots.executionWorkspaceId, f.parent.id));
expect(snapshot.metadata?.importedWorkspaceChildren).toHaveLength(2);
if (mode === "local edit") expect(await fs.readFile(path.join(f.leaves[0].cwd!, "untracked.txt"), "utf8")).toBe("later local bytes\n");
});
it("still refuses a child snapshot binding changed during the parent upload", async () => {
const f = await imported(), put = storage.putFile.bind(storage);
vi.spyOn(storage, "putFile").mockImplementation(async input => {
const result = await put(input);
await db.update(executionWorkspaceSnapshots).set({ manifestDigest: "f".repeat(64) }).where(eq(executionWorkspaceSnapshots.id, f.snapshots[0].id));
return result;
});
await expect(parent(f)).rejects.toThrow("online_workspace_source_scope_changed");
const [row] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.parent.id));
expect(readRemoteWorkspaceAuthority(row.metadata)).toBeNull();
expect(await fs.readFile(path.join(f.root, "saved.txt"), "utf8")).toBe("committed base\n");
});
it("refuses a selector inherited from another source-owner transaction", async () => {
const f = await imported(); let verifier!: ReturnType<typeof createNestedWorkspaceImportVerifier>;
await withWorkspaceSourceLocks(db, { companyId: f.companyId, projectIds: [f.projectId] }, async sourceLock => {
verifier = createNestedWorkspaceImportVerifier({ db, storage, companyId: f.companyId, projectIds: [f.projectId], issueIds: [], sourceLock });
});
try {
await withWorkspaceSourceLocks(db, { companyId: f.companyId, projectIds: [f.projectId] }, async () => {
await expect(verifier.prepare({ workspace: f.parent, scratch: home, recordedChildren: [] })).rejects.toThrow("nested_workspace_source_fence_changed");
});
} finally { await verifier.close(); }
await expect(verifier.prepare({ workspace: f.parent, scratch: home, recordedChildren: [] })).rejects.toThrow("nested_workspace_verifier_unavailable");
});
});