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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-04 18:25:08 -06:00
1 parent cda9460ae9
commit 9cdbcbeddc
4 files changed
+89 -4

No files matched your search

@@ -23,6 +23,14 @@ encrypted task-directory restoration, Git restoration recipes, and legacy
branch-deletion receipts. PostgreSQL and real-file tests prove cold restoration
of saved bytes, index state, and unpushed commits, including after pruning the
old Git object store. Invalid or unavailable retained data still blocks import.
An archived isolated checkout can be gone while its recorded deleted commit
survives in the company/project Git cache. After validating the exact successful
branch-deletion receipt, the importer can read immutable objects from that
namespace- and origin-checked cache. It reconstructs only the recorded commit,
never today's working tree. Ordinary private-checkout and restore-point
validation stays strict. Missing/revoked receipts, foreign caches and changed
origins still block publication.
The next slice adds leaf-first offline migration of overlapping folders, verified
nested snapshot dependencies, interruption recovery, and dependency retention.
Recovery-obligation reconciliation remains a separate gap; these slices do not
@@ -68,6 +68,12 @@ export async function verifiedGitCacheSource(workspace: Workspace, sourcePath: s
if (sourcePath !== privateSource) throw new GitCacheRestorationFailure();
return (await verifiedRecordedIsolatedRemoteSource(workspace))!;
}
return verifiedManagedGitObjectSource(workspace, sourcePath);
}
/** Read immutable commit objects from the company/project cache. This does not
* certify the files or restore-point contents of an isolated task checkout. */
export async function verifiedManagedGitObjectSource(workspace: Workspace, sourcePath: string) {
if (!managedSourcePaths(workspace).includes(sourcePath)) throw new GitCacheRestorationFailure();
const root = resolvePaperclipInstanceRoot();
const actual = await fs.realpath(sourcePath);
@@ -11,7 +11,7 @@ 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, type GitCacheRestoration } from "./git-workspace-restoration.js";
verifiedGitCacheSource, verifiedManagedGitObjectSource, type GitCacheRestoration } from "./git-workspace-restoration.js";
import { localRepositoryPath } from "./local-repository-path.js";
import { materializeManagedProjectWorkspace } from "./managed-repository-checkout.js";
@@ -24,6 +24,10 @@ export type ArchivedGitMigrationSource = {
};
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,
@@ -61,7 +65,7 @@ export async function inspectArchivedGitMigrationSource(db: Pick<Db, "select">,
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 verifiedGitCacheSource(workspace, sourcePath);
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 };
@@ -80,7 +84,12 @@ export async function materializeArchivedGitMigrationSource(input: {
throw error;
});
if (present) {
const verified = await verifiedGitCacheSource(workspace, source.sourcePath);
// 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);
@@ -7,7 +7,7 @@ import { promisify } from "node:util";
import { createServer, type Server } from "node:http";
import { pathToFileURL } from "node:url";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { activityLog, agents, environmentLeases, heartbeatRuns, nativeRunFinalizations, nativeRunResults, completionContracts, applyPendingMigrations, closeRegisteredClients, companies, createDb, executionWorkspaceSnapshots,
executionWorkspaceRuntimeLeases, executionWorkspaces, issues, projects, projectWorkspaces, workspaceOperations, workspaceRuntimeServices } from "@paperclipai/db";
import { readRemoteWorkspaceAuthority } from "@paperclipai/shared";
@@ -31,6 +31,8 @@ import { claimNativeRestartRecoveries } from "./native-runtime/native-restart-re
import { attemptNativeWorkspaceFinalization } from "./native-runtime/native-workspace-finalizer.js";
import { managedProjectCheckoutName } from "./managed-project-checkout-name.js";
import { captureGitWorkspaceContents, readManagedCheckoutContents } from "./git-workspace-contents.js";
import { isolatedRemoteSourcePath } from "./isolated-remote-workspace-source.js";
import { verifiedGitCacheSource } from "./git-workspace-restoration.js";
const execute = promisify(execFile);
const git = async (cwd: string, ...args: string[]) => (await execute("git", ["-C", cwd, ...args], { env: { ...process.env,
@@ -463,6 +465,66 @@ 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 => {
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 });
await git(root, "clone", "--no-hardlinks", f.base, privateBase);
await git(privateBase, "remote", "set-url", "origin", f.repoUrl);
await git(privateBase, "worktree", "add", "-b", f.branchName, privateCwd, `origin/${f.branchName}`);
await git(privateBase, "merge", "--ff-only", f.branchName);
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 });
const closedAt = new Date();
await db.update(executionWorkspaces).set({ cwd: privateCwd, providerRef: privateCwd, status: "archived", closedAt,
metadata: { createdByRuntime: true, gitBranchOwnershipVersion: 1, isolatedRemoteSource: {
schema: "paperclip.isolated-remote-source.v1", companyId: f.companyId, issueId: f.issueId, repoUrl: f.repoUrl } } })
.where(eq(executionWorkspaces.id, f.workspaceId));
if (state !== "missing-receipt") await db.insert(workspaceOperations).values({ companyId: f.companyId,
executionWorkspaceId: f.workspaceId, phase: "worktree_cleanup", status: "succeeded",
command: `git --git-dir=temporary branch -d ${f.branchName}`, stdoutExcerpt: receipt,
startedAt: new Date(), finishedAt: new Date(), metadata: { cleanupAction: "branch_delete", workspaceId: f.workspaceId,
branchName: f.branchName, workspacePath: privateCwd } });
// Keep ordinary private-workspace restoration strict. This project cache
// supplies immutable Git objects only for the proven deleted commit.
await expect(verifiedGitCacheSource(await workspace(f.workspaceId), f.base)).rejects.toThrow();
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 === "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);
await db.update(projectWorkspaces).set({ cwd: foreign }).where(eq(projectWorkspaces.id, f.projectWorkspaceId));
}
if (state === "revoked-receipt") {
const put = storage.putFile.bind(storage);
storage.putFile = async input => {
const result = await put(input);
await db.update(workspaceOperations).set({ status: "failed" }).where(and(
eq(workspaceOperations.executionWorkspaceId, f.workspaceId), eq(workspaceOperations.phase, "worktree_cleanup")));
return result;
};
}
if (state !== "valid") {
await expect(migrateRemoteWorkspace({ db, storage, companyId: f.companyId, workspaceId: f.workspaceId, apply: true })).rejects.toThrow();
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull(); return;
}
await migrateRemoteWorkspace({ db, storage, companyId: f.companyId, workspaceId: f.workspaceId, apply: true });
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 });
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 });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref!.manifestDigest });
expect(await git(targetDir, "rev-parse", "HEAD")).toBe(f.commit);
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("original saved work\n");
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();
});
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");