fix(workspaces): preserve retained branches when private checkouts are gone

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-04 22:04:33 -06:00
1 parent 06826915d7
commit 65a0116b96
4 files changed
+95 -14

No files matched your search

@@ -206,6 +206,15 @@ 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
@@ -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, or, sql } from "drizzle-orm";
import { and, desc, eq, gte, 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";
@@ -14,6 +14,7 @@ import { ensureSavedGitCommit, gitRestorePointExpired, managedSourcePaths, readG
verifiedGitCacheSource, verifiedManagedGitObjectSource, type GitCacheRestoration } from "./git-workspace-restoration.js";
import { localRepositoryPath } from "./local-repository-path.js";
import { materializeManagedProjectWorkspace } from "./managed-repository-checkout.js";
import { isRuntimeOwnedGitBranch } from "./execution-workspace-branch-ownership.js";
type Workspace = typeof executionWorkspaces.$inferSelect;
export type ArchivedGitMigrationSource = {
@@ -21,6 +22,7 @@ export type ArchivedGitMigrationSource = {
sourcePath: string;
cleanupOperationId: string | null;
recipe: GitCacheRestoration | null;
retainedBranch?: string;
};
const execute = promisify(execFile);
const unavailable = () => new Error("remote_workspace_migration_archived_git_source_unavailable");
@@ -38,15 +40,23 @@ async function git(cwd: string, args: string[]) {
/** A removed branch must be reconstructed from its saved commit or its exact
* successful deletion receipt. Today's default branch is never a substitute. */
export async function inspectArchivedGitMigrationSource(db: Pick<Db, "select">, workspace: Workspace): Promise<ArchivedGitMigrationSource | null> {
if (workspace.status !== "archived" || !workspace.repoUrl || !workspace.branchName || !workspace.sourceIssueId) return null;
export async function inspectArchivedGitMigrationSource(db: Pick<Db, "select">, workspace: Workspace,
options: { allowActiveRetainedBranch?: boolean } = {}): Promise<ArchivedGitMigrationSource | null> {
const archived = workspace.status === "archived";
if ((!archived && !options.allowActiveRetainedBranch) || !workspace.repoUrl || !workspace.branchName || !workspace.sourceIssueId) return null;
const recipe = readGitCacheRestoration(workspace);
if (recipe) {
if (!archived) return null;
if (gitRestorePointExpired(recipe)) throw new Error("remote_workspace_migration_restore_point_expired");
return { commit: recipe.commit, sourcePath: recipe.sourcePath, recipe, cleanupOperationId: null };
}
if (workspace.providerType !== "git_worktree" || !workspace.closedAt || workspace.metadata?.gitBranchOwnershipVersion !== 1) return null;
const [operation] = await db.select().from(workspaceOperations).where(and(
if (workspace.providerType !== "git_worktree" || workspace.metadata?.gitBranchOwnershipVersion !== 1) return null;
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 [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"),
or(eq(workspaceOperations.command, `git branch -d ${workspace.branchName}`), and(
@@ -55,16 +65,32 @@ export async function inspectArchivedGitMigrationSource(db: Pick<Db, "select">,
sql`${workspaceOperations.metadata}->>'branchName' = ${workspace.branchName}`,
sql`${workspaceOperations.metadata}->>'workspacePath' = ${workspace.providerRef ?? workspace.cwd}`,
)), gte(workspaceOperations.startedAt, workspace.closedAt),
)).orderBy(desc(workspaceOperations.startedAt), desc(workspaceOperations.id)).limit(1);
)).orderBy(desc(workspaceOperations.startedAt), desc(workspaceOperations.id)).limit(1) : [];
if (!operation) {
// 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;
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;
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),
eq(executionWorkspaces.repoUrl, workspace.repoUrl), eq(executionWorkspaces.branchName, workspace.branchName),
ne(executionWorkspaces.id, workspace.id),
)).limit(1);
if (other || (await git(sourcePath, ["worktree", "list", "--porcelain", "-z"]))
.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 };
}
const receipt = operation?.stdoutExcerpt?.trim(), prefix = `Deleted branch ${workspace.branchName} (was `;
if (!operation || !receipt?.startsWith(prefix) || !receipt.endsWith(").")) return null;
const abbreviation = receipt.slice(prefix.length, -2);
if (!/^[a-f0-9]{7,40}$/.test(abbreviation)) throw unavailable();
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();
await verifiedReceiptObjectSource(workspace, sourcePath);
const commit = await git(sourcePath, ["rev-parse", "--verify", `${abbreviation}^{commit}`]);
if (!/^[a-f0-9]{40}$/.test(commit)) throw unavailable();
@@ -87,9 +113,10 @@ export async function materializeArchivedGitMigrationSource(input: {
// 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();
if (!source.recipe && !source.cleanupOperationId && source.retainedBranch !== workspace.branchName) throw unavailable();
const verified = source.recipe ? await verifiedGitCacheSource(workspace, source.sourcePath)
: await verifiedReceiptObjectSource(workspace, source.sourcePath);
if (source.retainedBranch && (await git(verified, ["rev-parse", "--verify", `refs/heads/${source.retainedBranch}^{commit}`])) !== source.commit) throw unavailable();
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);
@@ -564,6 +564,50 @@ 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 => {
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 })
.where(eq(executionWorkspaces.id, f.workspaceId));
await git(f.base, "worktree", "remove", f.cwd);
await fs.writeFile(path.join(f.base, "base.txt"), "new shared default branch\n");
await git(f.base, "add", "."); await git(f.base, "commit", "-qm", "unrelated shared work");
if (mode === "changed-branch") {
const put = storage.putFile.bind(storage);
storage.putFile = async input => { const saved = await put(input); await git(f.base, "update-ref", `refs/heads/${f.branchName}`, await git(f.base, "rev-parse", "HEAD")); return saved; };
}
if (mode === "competing-owner") {
const [issue] = await db.insert(issues).values({ companyId: f.companyId, title: "Different branch owner" }).returning();
await db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: issue!.id, projectId: f.projectId,
name: "Other owner", mode: "isolated_workspace", strategyType: "git_worktree", providerType: "git_worktree",
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 === "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)) {
await expect(migrate()).rejects.toThrow();
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull(); return;
}
await migrate();
const row = await workspace(f.workspaceId), authority = readRemoteWorkspaceAuthority(row.metadata)!;
expect(row.status).toBe(active ? "active" : "archived");
expect(authority.source).toMatchObject({ kind: "git", ref: f.commit });
const [operation] = await db.select().from(workspaceOperations).where(and(
eq(workspaceOperations.executionWorkspaceId, f.workspaceId), eq(workspaceOperations.phase, "remote_workspace_migration")));
expect(operation!.metadata?.archivedGitSource).toMatchObject({ commit: f.commit, retainedBranch: f.branchName });
await fs.rm(f.base, { recursive: true });
const ref = await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! });
const archivePath = path.join(root, "retained-branch.pcws"), targetDir = path.join(root, "restored-retained-branch");
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, "base.txt"), "utf8")).toBe("base\n");
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("original saved work\n");
});
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");
@@ -49,6 +49,7 @@ function fingerprint(workspace: Workspace) {
}
const gitSourceProof = (source: ArchivedGitMigrationSource | null) => source ? {
commit: source.commit, sourcePath: source.sourcePath, cleanupOperationId: source.cleanupOperationId,
...(source.retainedBranch ? { retainedBranch: source.retainedBranch } : {}),
} : null;
async function inspect(db: ReadDb, input: { companyId: string; workspaceId: string; operationId?: string; writersStopped?: boolean }) {
@@ -85,8 +86,8 @@ async function inspect(db: ReadDb, input: { companyId: string; workspaceId: stri
}
}
}
if (!entry && workspace.status === "archived" && !retainedDirectory) {
try { archivedGitSource = await inspectArchivedGitMigrationSource(db, workspace); }
if (!entry && !retainedDirectory && (workspace.status === "archived" || input.writersStopped)) {
try { archivedGitSource = await inspectArchivedGitMigrationSource(db, workspace, { allowActiveRetainedBranch: input.writersStopped }); }
catch (error) { reasons.push(error instanceof Error && error.message === "remote_workspace_migration_restore_point_expired"
? "restore_point_expired" : "archived_git_source_unavailable"); }
}