mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 22:13:59 +01:00
fix(workspaces): preserve retained branches when private checkouts are gone
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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"); }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user