fix(workspaces): retain private objects and clean user-owned branch receipts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-04 22:05:38 -06:00
1 parent 65a0116b96
commit b28f72ef44
3 files changed
+66 -21

No files matched your search

@@ -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
@@ -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<Db, "select">,
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<Db, "select">,
// 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<Db, "select">,
.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;
@@ -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;
}