From b28f72ef447721442b6cd73d1182b5c4439e7097 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 4 Nov 2026 18:43:30 -0600 Subject: [PATCH] fix(workspaces): retain private objects and clean user-owned branch receipts Co-Authored-By: Paperclip --- ...-existing-workspace-ephemeral-migration.md | 25 ++++++++----- .../remote-workspace-migration-git.ts | 36 +++++++++++++++---- .../remote-workspace-migration.test.ts | 26 ++++++++++---- 3 files changed, 66 insertions(+), 21 deletions(-) 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 841eafa145f..d48c93e2b55 100644 --- a/doc/plans/2026-11-03-existing-workspace-ephemeral-migration.md +++ b/doc/plans/2026-11-03-existing-workspace-ephemeral-migration.md @@ -165,6 +165,22 @@ creation time. A queued follow-up can predate the prior turn completion. A remot stop can also predate a later logical failure caused by recovery timeout. These proofs use the recorded execution start and never rewrite lifecycle timestamps. +A missing managed task checkout can still have its runtime-owned branch in the +verified company and project Git cache. Preserve that exact commit when the +recorded private path matches, no other workspace owns the branch, and Git has +no surviving worktree registration or index for it. Pin and recheck the commit +before publication. This saves the surviving committed tree; it does not +certify recovery of files that were already absent. An active record requires +the existing offline provider fence and keeps its original status. Invalid +restore recipes, live registrations and competing branch owners stay blocked. + +For a user-owned retained branch, require its exact successful unforced worktree +removal receipt after archival. Forced removal or a revoked receipt does not +grant this authority. Preserve the branch and its ownership metadata unchanged. +For a deleted private branch, prefer its verified recorded private object source +while it still exists. A missing project cache or a later project-source change +must not hide that original retained commit. + ### Existing typed checkpoint authority When a retained source already has a valid typed database checkpoint, derive its @@ -206,15 +222,6 @@ finite offline operation adds no idle background work. A resumed rehearsal can measure the fix but cannot replace fresh full-image migration and empty-home recovery proof. -A missing managed task checkout can still have its runtime-owned branch in the -verified company and project Git cache. Preserve that exact commit when the -recorded private path matches, no other workspace owns the branch, and Git has -no surviving worktree registration or index for it. Pin and recheck the commit -before publication. This saves the surviving committed tree; it does not -certify recovery of files that were already absent. An active record requires -the existing offline provider fence and keeps its original status. Invalid -restore recipes, live registrations and competing branch owners stay blocked. - During offline migration, known task-only assignment, configuration, deliberate-wait and disposition warnings can remain unchanged while the owner saves local files. They do not certify file recovery and are not resolved by migration. An active diff --git a/server/src/services/remote-workspace-migration-git.ts b/server/src/services/remote-workspace-migration-git.ts index c674e6de741..5ee238a5526 100644 --- a/server/src/services/remote-workspace-migration-git.ts +++ b/server/src/services/remote-workspace-migration-git.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; -import { and, desc, eq, gte, ne, or, sql } from "drizzle-orm"; +import { and, desc, eq, gte, isNotNull, ne, or, sql } from "drizzle-orm"; import { executionWorkspaces, projectWorkspaces, workspaceOperations, type Db } from "@paperclipai/db"; import { PROJECT_REPOSITORIES_DIR } from "@paperclipai/adapter-utils/git-workspace-sync"; import type { StorageService } from "../storage/types.js"; @@ -15,6 +15,7 @@ import { ensureSavedGitCommit, gitRestorePointExpired, managedSourcePaths, readG import { localRepositoryPath } from "./local-repository-path.js"; import { materializeManagedProjectWorkspace } from "./managed-repository-checkout.js"; import { isRuntimeOwnedGitBranch } from "./execution-workspace-branch-ownership.js"; +import { recordedIsolatedRemoteSource } from "./isolated-remote-workspace-source.js"; type Workspace = typeof executionWorkspaces.$inferSelect; export type ArchivedGitMigrationSource = { @@ -54,8 +55,13 @@ export async function inspectArchivedGitMigrationSource(db: Pick, const [projectWorkspace] = workspace.projectWorkspaceId ? await db.select({ cwd: projectWorkspaces.cwd, repoUrl: projectWorkspaces.repoUrl }) .from(projectWorkspaces).where(and(eq(projectWorkspaces.id, workspace.projectWorkspaceId), 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(); + const privateSource = recordedIsolatedRemoteSource(workspace); + const privatePresent = privateSource ? await fs.lstat(privateSource).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }) : null; + const sourcePath = privatePresent && privateSource ? privateSource : projectWorkspace?.cwd ?? managedSourcePaths(workspace)[0]; + if (!sourcePath || !privatePresent && projectWorkspace?.repoUrl && projectWorkspace.repoUrl !== workspace.repoUrl) throw unavailable(); const [operation] = archived && workspace.closedAt ? await db.select().from(workspaceOperations).where(and( eq(workspaceOperations.companyId, workspace.companyId), eq(workspaceOperations.executionWorkspaceId, workspace.id), eq(workspaceOperations.phase, "worktree_cleanup"), eq(workspaceOperations.status, "succeeded"), @@ -70,11 +76,29 @@ export async function inspectArchivedGitMigrationSource(db: Pick, // A branch that was never deleted still owns its committed tree. It does // not certify lost untracked files. Any surviving worktree registration // can retain additional index/files authority and must stay blocked. - if (!isRuntimeOwnedGitBranch(workspace.metadata) || workspace.mode !== "isolated_workspace" - || !workspace.projectId || !managedSourcePaths(workspace).includes(sourcePath)) return null; + if (workspace.mode !== "isolated_workspace" || !workspace.projectId || !managedSourcePaths(workspace).includes(sourcePath)) return null; await git(sourcePath, ["check-ref-format", `refs/heads/${workspace.branchName}`]); const expected = path.join(sourcePath, ".paperclip", "worktrees", workspace.branchName); if (workspace.cwd !== expected || workspace.providerRef && workspace.providerRef !== expected) return null; + let removalId: string | null = null; + if (!isRuntimeOwnedGitBranch(workspace.metadata)) { + // User-owned branches must not be deleted. A successful unforced removal + // proves their old checkout was clean; preserve the branch without + // converting that receipt into branch-deletion authority. + if (workspace.metadata?.createdByRuntime !== false || !archived || !workspace.closedAt) return null; + const [removal] = await db.select({ id: workspaceOperations.id }).from(workspaceOperations).where(and( + eq(workspaceOperations.companyId, workspace.companyId), eq(workspaceOperations.executionWorkspaceId, workspace.id), + eq(workspaceOperations.phase, "worktree_cleanup"), eq(workspaceOperations.status, "succeeded"), + eq(workspaceOperations.command, `git worktree remove ${expected}`), isNotNull(workspaceOperations.finishedAt), + gte(workspaceOperations.startedAt, workspace.closedAt), + sql`${workspaceOperations.metadata}->>'cleanupAction' = 'worktree_remove'`, + sql`${workspaceOperations.metadata}->>'workspaceId' = ${workspace.id}`, + sql`${workspaceOperations.metadata}->>'workspacePath' = ${expected}`, + sql`${workspaceOperations.metadata}->>'branchName' = ${workspace.branchName}`, + )).orderBy(desc(workspaceOperations.startedAt), desc(workspaceOperations.id)).limit(1); + if (!removal) return null; + removalId = removal.id; + } await verifiedManagedGitObjectSource(workspace, sourcePath); const [other] = await db.select({ id: executionWorkspaces.id }).from(executionWorkspaces).where(and( eq(executionWorkspaces.companyId, workspace.companyId), eq(executionWorkspaces.projectId, workspace.projectId), @@ -85,7 +109,7 @@ export async function inspectArchivedGitMigrationSource(db: Pick, .split("\0").includes(`branch refs/heads/${workspace.branchName}`)) throw unavailable(); const commit = await git(sourcePath, ["rev-parse", "--verify", `refs/heads/${workspace.branchName}^{commit}`]); if (!/^[a-f0-9]{40}$/.test(commit)) throw unavailable(); - return { commit, sourcePath, cleanupOperationId: null, recipe: null, retainedBranch: workspace.branchName }; + return { commit, sourcePath, cleanupOperationId: removalId, recipe: null, retainedBranch: workspace.branchName }; } const receipt = operation?.stdoutExcerpt?.trim(), prefix = `Deleted branch ${workspace.branchName} (was `; if (!operation || !receipt?.startsWith(prefix) || !receipt.endsWith(").")) return null; diff --git a/server/src/services/remote-workspace-migration.test.ts b/server/src/services/remote-workspace-migration.test.ts index d5feff210c2..d0676a3f5ca 100644 --- a/server/src/services/remote-workspace-migration.test.ts +++ b/server/src/services/remote-workspace-migration.test.ts @@ -504,7 +504,7 @@ 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 => { + it.each(["valid", "foreign-cache", "wrong-origin", "missing-receipt", "revoked-receipt", "private-cache"])("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 }); @@ -515,7 +515,8 @@ describe.skipIf(!support.supported)("remote workspace migration", () => { 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 }); + await git(f.base, "branch", "-d", f.branchName); + if (state !== "private-cache") 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: { @@ -532,7 +533,7 @@ describe.skipIf(!support.supported)("remote workspace migration", () => { 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 === "wrong-origin" || state === "private-cache") 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); @@ -547,7 +548,7 @@ describe.skipIf(!support.supported)("remote workspace migration", () => { return result; }; } - if (state !== "valid") { + if (state !== "valid" && state !== "private-cache") { await expect(migrateRemoteWorkspace({ db, storage, companyId: f.companyId, workspaceId: f.workspaceId, apply: true })).rejects.toThrow(); expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull(); return; } @@ -555,6 +556,7 @@ describe.skipIf(!support.supported)("remote workspace migration", () => { 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 }); + await fs.rm(privateBase, { recursive: true, force: 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 }); @@ -564,7 +566,7 @@ describe.skipIf(!support.supported)("remote workspace migration", () => { 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(); }); - it.each(["valid", "changed-branch", "competing-owner", "registered-branch", "unowned", "missing-branch", "active-online", "active-offline"])("preserves a missing checkout from its retained owned branch (%s)", async mode => { + it.each(["valid", "changed-branch", "competing-owner", "registered-branch", "unowned", "missing-branch", "active-online", "active-offline", "user-owned-receipt", "user-owned-force-receipt", "user-owned-revoked-receipt"])("preserves a missing checkout from its retained owned branch (%s)", async mode => { const f = await gitArchiveFixture(), closedAt = new Date(); const active = mode.startsWith("active-"); await db.update(executionWorkspaces).set({ status: active ? "active" : "archived", closedAt: active ? null : closedAt }) @@ -583,12 +585,24 @@ describe.skipIf(!support.supported)("remote workspace migration", () => { repoUrl: f.repoUrl, branchName: f.branchName, cwd: path.join(root, "other-owner") }); } if (mode === "registered-branch") await git(f.base, "worktree", "add", path.join(root, "registered"), f.branchName); + if (mode.startsWith("user-owned-")) { + await db.update(executionWorkspaces).set({ metadata: { createdByRuntime: false, gitBranchOwnershipVersion: 1 } }).where(eq(executionWorkspaces.id, f.workspaceId)); + const [receipt] = await db.insert(workspaceOperations).values({ companyId: f.companyId, executionWorkspaceId: f.workspaceId, + phase: "worktree_cleanup", status: "succeeded", startedAt: new Date(), finishedAt: new Date(), + command: `git worktree remove ${mode === "user-owned-force-receipt" ? "--force " : ""}${f.cwd}`, + metadata: { cleanupAction: "worktree_remove", workspaceId: f.workspaceId, workspacePath: f.cwd, branchName: f.branchName } }).returning(); + if (mode === "user-owned-revoked-receipt") { + const put = storage.putFile.bind(storage); + storage.putFile = async input => { const saved = await put(input); await db.update(workspaceOperations).set({ status: "failed" }) + .where(eq(workspaceOperations.id, receipt!.id)); return saved; }; + } + } if (mode === "unowned") await db.update(executionWorkspaces).set({ metadata: { gitBranchOwnershipVersion: 1 } }).where(eq(executionWorkspaces.id, f.workspaceId)); if (mode === "missing-branch") await git(f.base, "branch", "-D", f.branchName); const offline = mode === "active-offline"; if (offline) vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true"); const migrate = () => migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: offline }); - if (!["valid", "active-offline"].includes(mode)) { + if (!["valid", "active-offline", "user-owned-receipt"].includes(mode)) { await expect(migrate()).rejects.toThrow(); expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull(); return; }