Files
PaperClipAI/server/src/services/remote-workspace-migration-git.ts
T
DottaandPaperclip 9cdbcbeddc fix(workspaces): restore archived commits from verified project objects (#19138)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - An ephemeral web server must preserve historical task files outside
its local disk.
> - Archived isolated Git workspaces can have a successful
branch-deletion receipt.
> - Their private checkout can be gone while the project cache still
owns the exact commit.
> - The importer rejects that cache because it applies private-checkout
validation.
> - This pull request validates the managed object source for
receipt-based restoration.
> - The importer restores the recorded commit without using the current
shared working tree.

## Linked Issues or Issue Description

Refs #18826, #18943, #19038.

**What happened?**

An archived isolated workspace has a valid successful cleanup receipt
and a retained commit. Offline migration rejects it with
`archived_git_source_unavailable,local_source_unsafe_or_missing`. The
private checkout was removed. The company and project cache still
contains the exact commit.

**Expected behavior**

Restore the immutable commit from the verified project object cache.
Keep strict validation for saved private edit recipes. Reject missing
receipts, a foreign cache, a different origin and revoked cleanup
authority.

**Steps to reproduce**

1. Create an isolated Git workspace from a managed project cache.
2. Archive it and retain its successful branch-deletion receipt.
3. Remove its private checkout while the verified project cache retains
that commit.
4. Attempt offline workspace migration.

## What Changed

- Extract the existing managed Git object-source validator.
- Use that validator only for exact successful deletion receipts that
refer to a canonical company and project cache.
- Keep private restore-point source validation unchanged.
- Fetch immutable objects at the recorded commit into private scratch.
Recheck receipt authority before publication.
- Add real Git and database tests for restoration and rejected authority
changes. Document the preservation path.

## Verification

- 129 focused migration, concurrency and isolated-source tests pass
across three files.
- The new restoration case fails against the previous importer with the
reported source-unavailable error.
- Tests remove the old home and restore the retained commit. A newer
shared working tree and unrelated files are excluded.
- Tests reject a foreign company cache, wrong origin, missing receipt
and receipt revocation during upload.
- Local server typecheck has no diagnostics in changed files. Stale
linked dependency declarations prevent a clean local whole-server
result. Current-head full CI passes (54 passing checks, three skipped).
Greptile is 5/5 with no unresolved review threads.
- A replay of the 32 affected records is running in an isolated staging
copy. Two committed owner overlays are under test. This is not
released-image or serving-instance acceptance.

## Risks

- A deletion receipt must identify an exact commit that still exists.
Missing objects continue to block migration.
- Shared working-tree contents must never substitute for archived
private files. Only the recorded commit is fetched through this path.
- The change does not recreate private edit authority, retire recovery
records or alter serving configuration.

## Model Used

OpenAI GPT-6.1 Codex, exact model ID `gpt-6.1-sol`. Used for reasoning,
code editing, shell tools and test execution. The runtime does not
expose its context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-04 18:25:08 -06:00

152 lines
10 KiB
TypeScript

import { execFile } from "node:child_process";
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 { 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";
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, verifiedManagedGitObjectSource, type GitCacheRestoration } from "./git-workspace-restoration.js";
import { localRepositoryPath } from "./local-repository-path.js";
import { materializeManagedProjectWorkspace } from "./managed-repository-checkout.js";
type Workspace = typeof executionWorkspaces.$inferSelect;
export type ArchivedGitMigrationSource = {
commit: string;
sourcePath: string;
cleanupOperationId: string | null;
recipe: GitCacheRestoration | null;
};
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,
env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull,
GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0" },
})).stdout.trim();
}
/** 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;
const recipe = readGitCacheRestoration(workspace);
if (recipe) {
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(
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(
sql`${workspaceOperations.metadata}->>'cleanupAction' = 'branch_delete'`,
sql`${workspaceOperations.metadata}->>'workspaceId' = ${workspace.id}`,
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);
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();
return { commit, sourcePath, cleanupOperationId: operation.id, recipe: null };
}
/** Restore only into private migration scratch. Original repositories, task
* folders, lifecycle state and restore-point metadata remain unchanged. */
export async function materializeArchivedGitMigrationSource(input: {
db: Db; storage: StorageService; workspace: Workspace; source: ArchivedGitMigrationSource; scratch: string;
}) {
const { workspace, source } = input, cwd = path.join(input.scratch, "retained-git");
if (!workspace.sourceIssueId || !workspace.repoUrl || !workspace.branchName) throw unavailable();
const resolveGitAuth = createIssueGitRemoteAuthProvider(input.db, workspace.companyId, workspace.sourceIssueId);
const present = await fs.lstat(source.sourcePath).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (present) {
// 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);
if (presentCommit) {
await git(cwd, ["fetch", "--no-tags", "--no-recurse-submodules", "--", verified, source.commit]);
} else if (source.recipe?.restorePoint && !source.recipe.restorePoint.headOnRemote) {
// The archived branch can have been pruned from the base while its
// encrypted bundle still owns the unpushed commits. Import only that
// bundle's explicit prerequisites, then let the restore-point owner
// verify and import the saved history below.
const { manifest } = await loadGitRestorePoint({ storage: input.storage, workspace, ref: source.recipe.restorePoint });
for (const commit of manifest.prerequisites) {
const local = await git(verified, ["cat-file", "-e", `${commit}^{commit}`]).then(() => true, () => false);
if (local) await git(cwd, ["fetch", "--no-tags", "--no-recurse-submodules", "--", verified, commit]);
else await ensureSavedGitCommit(cwd, workspace.repoUrl, commit, resolveGitAuth);
}
}
} else {
if (!source.recipe) throw unavailable();
await materializeManagedProjectWorkspace(cwd, { repoUrl: workspace.repoUrl, resolveGitAuth });
}
if (source.recipe?.restorePoint) await ensureGitRestorePointCommit({ storage: input.storage, workspace,
ref: source.recipe.restorePoint, repositoryCwd: cwd });
await ensureSavedGitCommit(cwd, workspace.repoUrl, source.commit, resolveGitAuth);
await git(cwd, ["check-ref-format", `refs/heads/${workspace.branchName}`]);
await git(cwd, ["checkout", "--detach", source.commit]);
await git(cwd, ["update-ref", `refs/heads/${workspace.branchName}`, source.commit]);
await git(cwd, ["symbolic-ref", "HEAD", `refs/heads/${workspace.branchName}`]);
await git(cwd, ["config", "remote.origin.url", workspace.repoUrl]);
if (source.recipe?.restorePoint) await applyGitRestorePointToWorktree({ storage: input.storage, workspace,
ref: source.recipe.restorePoint, repositoryCwd: cwd, worktreePath: cwd });
const approvedLocalSources = new Set<string>();
await restoreGitWorkspaceContents({ cwd, companyId: workspace.companyId, workspaceId: workspace.id,
projectId: workspace.projectId, contents: source.recipe?.contents, storage: input.storage, resolveGitAuth,
authorizeSource: async saved => {
if (!workspace.projectId) throw unavailable();
const [row] = await input.db.select().from(projectWorkspaces).where(and(eq(projectWorkspaces.id, saved.id),
eq(projectWorkspaces.companyId, workspace.companyId), eq(projectWorkspaces.projectId, workspace.projectId)));
if (!row || row.repoUrl !== saved.repoUrl) throw unavailable();
const local = localRepositoryPath(saved.repoUrl); if (local) approvedLocalSources.add(local);
},
prepareLocalSource: async local => {
if (!approvedLocalSources.has(local)) throw unavailable();
const identity = await fs.realpath(local);
return async () => { if (await fs.realpath(local) !== identity) throw unavailable(); };
},
});
// A checkout records scratch-file inode and time fields in its index. Those
// fields must not change the saved archive identity after an upload reply is
// lost. Rebuild only these reconstructed indexes from their staged trees;
// the original repository and unstaged files remain untouched.
for (const repository of [cwd, ...(source.recipe?.contents?.checkouts ?? [])
.map(checkout => path.join(cwd, PROJECT_REPOSITORIES_DIR, checkout.name))]) {
const tree = await git(repository, ["write-tree"]);
await git(repository, ["read-tree", "--empty"]);
await git(repository, ["read-tree", tree]);
}
return cwd;
}