diff --git a/doc/plans/2026-11-03-existing-workspace-ephemeral-migration.md b/doc/plans/2026-11-03-existing-workspace-ephemeral-migration.md index a739c0bf59f..925aec1ebfe 100644 --- a/doc/plans/2026-11-03-existing-workspace-ephemeral-migration.md +++ b/doc/plans/2026-11-03-existing-workspace-ephemeral-migration.md @@ -23,6 +23,14 @@ encrypted task-directory restoration, Git restoration recipes, and legacy branch-deletion receipts. PostgreSQL and real-file tests prove cold restoration of saved bytes, index state, and unpushed commits, including after pruning the old Git object store. Invalid or unavailable retained data still blocks import. + +An archived isolated checkout can be gone while its recorded deleted commit +survives in the company/project Git cache. After validating the exact successful +branch-deletion receipt, the importer can read immutable objects from that +namespace- and origin-checked cache. It reconstructs only the recorded commit, +never today's working tree. Ordinary private-checkout and restore-point +validation stays strict. Missing/revoked receipts, foreign caches and changed +origins still block publication. The next slice adds leaf-first offline migration of overlapping folders, verified nested snapshot dependencies, interruption recovery, and dependency retention. Recovery-obligation reconciliation remains a separate gap; these slices do not diff --git a/server/src/services/git-workspace-restoration.ts b/server/src/services/git-workspace-restoration.ts index 5aacb475a80..b84325b0d83 100644 --- a/server/src/services/git-workspace-restoration.ts +++ b/server/src/services/git-workspace-restoration.ts @@ -68,6 +68,12 @@ export async function verifiedGitCacheSource(workspace: Workspace, sourcePath: s if (sourcePath !== privateSource) throw new GitCacheRestorationFailure(); return (await verifiedRecordedIsolatedRemoteSource(workspace))!; } + return verifiedManagedGitObjectSource(workspace, sourcePath); +} + +/** Read immutable commit objects from the company/project cache. This does not + * certify the files or restore-point contents of an isolated task checkout. */ +export async function verifiedManagedGitObjectSource(workspace: Workspace, sourcePath: string) { if (!managedSourcePaths(workspace).includes(sourcePath)) throw new GitCacheRestorationFailure(); const root = resolvePaperclipInstanceRoot(); const actual = await fs.realpath(sourcePath); diff --git a/server/src/services/remote-workspace-migration-git.ts b/server/src/services/remote-workspace-migration-git.ts index 66d82639c38..5f905fae544 100644 --- a/server/src/services/remote-workspace-migration-git.ts +++ b/server/src/services/remote-workspace-migration-git.ts @@ -11,7 +11,7 @@ import { createIssueGitRemoteAuthProvider } from "./git-credentials.js"; import { applyGitRestorePointToWorktree, ensureGitRestorePointCommit, loadGitRestorePoint } from "./git-restore-point-storage.js"; import { restoreGitWorkspaceContents } from "./git-workspace-contents.js"; import { ensureSavedGitCommit, gitRestorePointExpired, managedSourcePaths, readGitCacheRestoration, - verifiedGitCacheSource, type GitCacheRestoration } from "./git-workspace-restoration.js"; + verifiedGitCacheSource, verifiedManagedGitObjectSource, type GitCacheRestoration } from "./git-workspace-restoration.js"; import { localRepositoryPath } from "./local-repository-path.js"; import { materializeManagedProjectWorkspace } from "./managed-repository-checkout.js"; @@ -24,6 +24,10 @@ export type ArchivedGitMigrationSource = { }; const execute = promisify(execFile); const unavailable = () => new Error("remote_workspace_migration_archived_git_source_unavailable"); +function verifiedReceiptObjectSource(workspace: Workspace, sourcePath: string) { + return managedSourcePaths(workspace).includes(sourcePath) + ? verifiedManagedGitObjectSource(workspace, sourcePath) : verifiedGitCacheSource(workspace, sourcePath); +} async function git(cwd: string, args: string[]) { return (await execute("git", ["-c", "core.fsmonitor=false", "-c", `core.hooksPath=${os.devNull}`, "-C", cwd, ...args], { timeout: 120_000, maxBuffer: 1024 * 1024, @@ -61,7 +65,7 @@ export async function inspectArchivedGitMigrationSource(db: Pick, eq(projectWorkspaces.companyId, workspace.companyId), workspace.projectId ? eq(projectWorkspaces.projectId, workspace.projectId) : undefined)) : []; const sourcePath = projectWorkspace?.cwd ?? managedSourcePaths(workspace)[0]; if (!sourcePath || projectWorkspace?.repoUrl && projectWorkspace.repoUrl !== workspace.repoUrl) throw unavailable(); - await verifiedGitCacheSource(workspace, sourcePath); + await verifiedReceiptObjectSource(workspace, sourcePath); const commit = await git(sourcePath, ["rev-parse", "--verify", `${abbreviation}^{commit}`]); if (!/^[a-f0-9]{40}$/.test(commit)) throw unavailable(); return { commit, sourcePath, cleanupOperationId: operation.id, recipe: null }; @@ -80,7 +84,12 @@ export async function materializeArchivedGitMigrationSource(input: { throw error; }); if (present) { - const verified = await verifiedGitCacheSource(workspace, source.sourcePath); + // A deletion receipt owns a specific committed tree. Its objects can come + // from the verified project cache after the private checkout was removed. + // Saved private edits still require their original restore-point source. + if (!source.recipe && !source.cleanupOperationId) throw unavailable(); + const verified = source.recipe ? await verifiedGitCacheSource(workspace, source.sourcePath) + : await verifiedReceiptObjectSource(workspace, source.sourcePath); await fs.mkdir(cwd, { mode: 0o700 }); await git(cwd, ["init", "--quiet"]); // Fetch objects, never the current working tree of a shared project root. const presentCommit = await git(verified, ["cat-file", "-e", `${source.commit}^{commit}`]).then(() => true, () => false); diff --git a/server/src/services/remote-workspace-migration.test.ts b/server/src/services/remote-workspace-migration.test.ts index 498ac0797d7..730aa200ae0 100644 --- a/server/src/services/remote-workspace-migration.test.ts +++ b/server/src/services/remote-workspace-migration.test.ts @@ -7,7 +7,7 @@ import { promisify } from "node:util"; import { createServer, type Server } from "node:http"; import { pathToFileURL } from "node:url"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { activityLog, agents, environmentLeases, heartbeatRuns, nativeRunFinalizations, nativeRunResults, completionContracts, applyPendingMigrations, closeRegisteredClients, companies, createDb, executionWorkspaceSnapshots, executionWorkspaceRuntimeLeases, executionWorkspaces, issues, projects, projectWorkspaces, workspaceOperations, workspaceRuntimeServices } from "@paperclipai/db"; import { readRemoteWorkspaceAuthority } from "@paperclipai/shared"; @@ -31,6 +31,8 @@ import { claimNativeRestartRecoveries } from "./native-runtime/native-restart-re import { attemptNativeWorkspaceFinalization } from "./native-runtime/native-workspace-finalizer.js"; import { managedProjectCheckoutName } from "./managed-project-checkout-name.js"; import { captureGitWorkspaceContents, readManagedCheckoutContents } from "./git-workspace-contents.js"; +import { isolatedRemoteSourcePath } from "./isolated-remote-workspace-source.js"; +import { verifiedGitCacheSource } from "./git-workspace-restoration.js"; const execute = promisify(execFile); const git = async (cwd: string, ...args: string[]) => (await execute("git", ["-C", cwd, ...args], { env: { ...process.env, @@ -463,6 +465,66 @@ describe.skipIf(!support.supported)("remote workspace migration", () => { expect(await fs.readFile(path.join(targetDir, "base.txt"), "utf8")).toBe("base\n"); expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("original saved work\n"); }); + it.each(["valid", "foreign-cache", "wrong-origin", "missing-receipt", "revoked-receipt"])("recovers deleted isolated history only from an authorized receipt and project object cache (%s)", async state => { + const f = await gitArchiveFixture(), privateBase = isolatedRemoteSourcePath(f.companyId, f.issueId); + const privateCwd = path.join(privateBase, ".paperclip", "worktrees", f.branchName); + await fs.mkdir(path.dirname(privateBase), { recursive: true }); + await git(root, "clone", "--no-hardlinks", f.base, privateBase); + await git(privateBase, "remote", "set-url", "origin", f.repoUrl); + await git(privateBase, "worktree", "add", "-b", f.branchName, privateCwd, `origin/${f.branchName}`); + await git(privateBase, "merge", "--ff-only", f.branchName); + await git(privateBase, "worktree", "remove", privateCwd); + const receipt = await git(privateBase, "branch", "-d", f.branchName); + await git(f.base, "merge", "--ff-only", f.branchName); await git(f.base, "worktree", "remove", f.cwd); + await git(f.base, "branch", "-d", f.branchName); await fs.rm(privateBase, { recursive: true }); + const closedAt = new Date(); + await db.update(executionWorkspaces).set({ cwd: privateCwd, providerRef: privateCwd, status: "archived", closedAt, + metadata: { createdByRuntime: true, gitBranchOwnershipVersion: 1, isolatedRemoteSource: { + schema: "paperclip.isolated-remote-source.v1", companyId: f.companyId, issueId: f.issueId, repoUrl: f.repoUrl } } }) + .where(eq(executionWorkspaces.id, f.workspaceId)); + if (state !== "missing-receipt") await db.insert(workspaceOperations).values({ companyId: f.companyId, + executionWorkspaceId: f.workspaceId, phase: "worktree_cleanup", status: "succeeded", + command: `git --git-dir=temporary branch -d ${f.branchName}`, stdoutExcerpt: receipt, + startedAt: new Date(), finishedAt: new Date(), metadata: { cleanupAction: "branch_delete", workspaceId: f.workspaceId, + branchName: f.branchName, workspacePath: privateCwd } }); + // Keep ordinary private-workspace restoration strict. This project cache + // supplies immutable Git objects only for the proven deleted commit. + await expect(verifiedGitCacheSource(await workspace(f.workspaceId), f.base)).rejects.toThrow(); + await fs.writeFile(path.join(f.base, "base.txt"), "new shared checkout\n"); + await fs.writeFile(path.join(f.base, "unrelated.txt"), "not task history\n"); + await git(f.base, "add", "."); await git(f.base, "commit", "-qm", "new shared default"); + if (state === "wrong-origin") await git(f.base, "remote", "set-url", "origin", "https://example.invalid/foreign.git"); + if (state === "foreign-cache") { + const foreign = resolveManagedProjectWorkspaceDir({ companyId: randomUUID(), projectId: f.projectId, repoName: "repository" }); + await fs.mkdir(path.dirname(foreign), { recursive: true }); await fs.rename(f.base, foreign); + await db.update(projectWorkspaces).set({ cwd: foreign }).where(eq(projectWorkspaces.id, f.projectWorkspaceId)); + } + if (state === "revoked-receipt") { + const put = storage.putFile.bind(storage); + storage.putFile = async input => { + const result = await put(input); + await db.update(workspaceOperations).set({ status: "failed" }).where(and( + eq(workspaceOperations.executionWorkspaceId, f.workspaceId), eq(workspaceOperations.phase, "worktree_cleanup"))); + return result; + }; + } + if (state !== "valid") { + await expect(migrateRemoteWorkspace({ db, storage, companyId: f.companyId, workspaceId: f.workspaceId, apply: true })).rejects.toThrow(); + expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull(); return; + } + await migrateRemoteWorkspace({ db, storage, companyId: f.companyId, workspaceId: f.workspaceId, apply: true }); + const row = await workspace(f.workspaceId), authority = readRemoteWorkspaceAuthority(row.metadata)!; + expect(row.status).toBe("archived"); expect(authority.source).toMatchObject({ kind: "git", ref: f.commit }); + await fs.rm(f.base, { recursive: true }); + const ref = await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! }); + const archivePath = path.join(root, "isolated-history.pcws"), targetDir = path.join(root, "restored-isolated-history"); + await loadWorkspaceSnapshotArchive({ storage, reference: ref!, targetPath: archivePath }); + await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref!.manifestDigest }); + expect(await git(targetDir, "rev-parse", "HEAD")).toBe(f.commit); + expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("original saved work\n"); + expect(await fs.readFile(path.join(targetDir, "base.txt"), "utf8")).toBe("base\n"); + expect(await fs.stat(path.join(targetDir, "unrelated.txt")).catch(() => null)).toBeNull(); + }); async function serveRepository() { const directory = path.join(root, "http"), bare = path.join(directory, "repository.git"); await fs.mkdir(bare, { recursive: true }); await git(bare, "init", "--bare", "--initial-branch=main");