mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## 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. - Ate876dbdc: 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. - Ate876dbdc: full archive and cohort suites: 147 passed. - Ate876dbdc: server layered typecheck passed. - At58e5c229: 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>
135 lines
11 KiB
TypeScript
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");
|
|
});
|
|
});
|