Recover archived commits from original bare sources (#19855)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Workspace migration must preserve saved files before the web server
releases its local volume.
> - An archived Git checkout can retain a trusted branch-deletion
receipt after its private clone and generated project cache are removed.
> - The original repository can still exist at its recorded file URL,
but the archive owner did not check that source.
> - This pull request permits a narrow, verified original
bare-repository source for the exact recorded commit.
> - The existing encrypted snapshot and publication owners then preserve
the workspace through their normal migration path.

## Linked Issues or Issue Description

No public issue exists. Searches for the archived-source error and
original bare Git source found no duplicate change.

**What happened?**

Migration reported `archived_git_source_unavailable` for an archived
checkout even though its original bare repository still contained the
exact saved commit. The private clone and generated cache were absent.

**Expected behavior**

The canonical owner can use the recorded original local repository when
its full commit identity and source ownership are proven. Uncertain or
changed sources remain blocked.

**Steps to reproduce**

1. Create an isolated checkout from a local bare repository.
2. Close it through the normal cleanup owner and retain the successful
branch-deletion receipt.
3. Remove the private clone and generated cache, while retaining the
recorded original repository and full base revision.
4. Inspect the archived workspace for migration.

**Paperclip version or commit**

Reproduced on `84908d176b2800f4c792c4249c610ec0baebdd28`.

**Deployment mode**

A source installation with existing local Git workspaces and shared
object storage.

## What Changed

- Consider the exact configured file-URL repository only after the
canonical primary source is definitively absent.
- Require a matching source record and successful cleanup receipt, the
exact full base/deleted commit, a contained bare SHA-1 repository,
bounded standard configuration and alias-free metadata.
- Restrict this source path to local Git transport and a minimal
environment. Reconstruct the tree only in private migration scratch.
- Retain existing source fingerprints, journal identity, encrypted
upload/readback and final publication checks. Lock and recheck the
configured source and cleanup record before publication.
- Add real PostgreSQL/Git coverage for encrypted cold restore,
lost-reply retry and refused source or publication changes.

## Verification

- The unchanged owner failed the real four-object original-source
regression with an ineligible inventory.
- The review regressions failed before each repair: four
returned-primary retry/publication cases and two actual production
metadata/reaper cases.
- Final head `8925519310` passed all 40 focused PostgreSQL/Git tests in
56.38 seconds. Four positive formats use canonical/legacy metadata and
ordinary/verified-tip cleanup. Each imports an encrypted snapshot,
removes the original file URI from reach, then authenticates and
cold-restores exact files, executable mode and Git history.
- The same file covers source/receipt/writer guards, changed sources
before publication, authenticated lost-reply reuse and 18
returned-primary directory/alias/unreadable cases. Refused publication
retains returned edits and uploaded ciphertext.
- Server TypeScript, module-boundary checks and `git diff --check`
passed on this revision. Canonical test wrappers exited and cleaned up.
- An earlier focused run also passed 22 existing
archived-Git/restore-point controls. `pnpm verify --explain` completed
without executing extra suites. Required CI supplies final broad
coverage.
- Final head passed all 60 CI/review contexts: 56 successful checks and
four expected skips. Greptile reviewed this exact head at 5/5, with no
open review threads.
- One unchanged archive segment-boundary test timed out on both
framework attempts in the initial server shard. The one job-only retry
passed with unchanged code and timeout. The test and its resolved local
import graph match a green control revision. The original failures
remain retained; the cause is unresolved.

## Risks

- The fallback is intentionally narrow. Original repositories with a
different full tip, unsupported configuration, aliases (including an
aliased instance root), external object storage or more than 10,000
metadata entries remain blocked.
- The first source check and final publication check add bounded local
filesystem and Git work to this maintenance path. They add no recurring
worker or network Git access.
- Original repository files remain intact. Importing a retained
workspace does not make its configured local file URL portable for
future fresh project work. That separate configuration dependency still
requires preservation and qualification before volume removal.
- The local regression uses the real encryption and restore owners with
a local object-storage backend. It does not claim a hosted backfill or a
deployed migration.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository tools and code
execution. The exact serving snapshot and context-window limit are not
exposed in this session.

## 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
#` 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-07 11:05:25 -06:00
1 parent a9ac2e2039
commit 4185e30865
3 files changed
+347 -21

No files matched your search

+24
View File
@@ -126,6 +126,30 @@ Receipt validation adds bounded per-scope database reads to startup eligibility.
It does not add a recurring scan or network work. Qualification must measure the
selected cohort's startup latency on the serving revision before enrollment.
### Original local Git sources for archived workspaces
When an archived runtime branch has an exact successful deletion receipt, its
saved commit can be imported from the recorded original file-URL repository if
the canonical private/project cache is definitively absent. A present, corrupt,
foreign or unreadable primary source never falls through to this path.
This fallback requires the matching company/project source record, a real bare
repository under the same instance root, and the exact full saved base SHA matching
the deleted tip (`baseRefSnapshot.resolvedSha`, with the legacy `baseRefSha` form
accepted when no canonical snapshot exists). Conflicting records fail closed. It accepts self-contained SHA-1 storage with a bounded standard
configuration and at most 10,000 metadata entries. Aliases, special files,
alternates, replacement/graft/shallow state and execution hooks fail closed.
Every retained branch or lightweight tag must still name that full base commit.
Saved retries and final publication recheck that all recorded private and generated
primary sources remain absent. Returned or unreadable primary files block the
original-source path. Git reads use a restricted environment and local-file transport only.
Materialization uses private migration scratch. Existing encrypted snapshot
readback, journal identity, source reinspection and publication fences still
apply. Original repository bytes and cleanup history remain unchanged. This
imports the retained workspace tree; it does not turn a configured local file
URL into a portable source for future fresh project work.
### Proactive agent chat messages
Migration `0419_slippery_impossible_man.sql` adds an agent-scoped unique constraint
@@ -18,6 +18,7 @@ import { materializeManagedProjectWorkspace } from "./managed-repository-checkou
import { isRuntimeOwnedGitBranch } from "./execution-workspace-branch-ownership.js";
import { physicalPath } from "./remote-workspace-migration-tree.js";
import { formatCommandForDisplay } from "./workspace-command-display.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
import { recordedIsolatedRemoteSource } from "./isolated-remote-workspace-source.js";
type Workspace = typeof executionWorkspaces.$inferSelect;
@@ -30,7 +31,84 @@ export type ArchivedGitMigrationSource = {
};
const execute = promisify(execFile);
const unavailable = () => new Error("remote_workspace_migration_archived_git_source_unavailable");
function verifiedReceiptObjectSource(workspace: Workspace, sourcePath: string) {
function originalLocalSource(workspace: Workspace, sourcePath: string) {
return Boolean(workspace.repoUrl && /^file:/i.test(workspace.repoUrl) && localRepositoryPath(workspace.repoUrl) === sourcePath
&& !managedSourcePaths(workspace).includes(sourcePath) && recordedIsolatedRemoteSource(workspace) !== sourcePath);
}
function originalRecordedCommit(workspace: Workspace) {
const snapshot = workspace.metadata?.baseRefSnapshot, legacy = workspace.metadata?.baseRefSha;
if (snapshot != null) {
if (typeof snapshot !== "object" || Array.isArray(snapshot)) return null;
const canonical = (snapshot as Record<string, unknown>).resolvedSha;
return typeof canonical === "string" && /^[a-f0-9]{40}$/.test(canonical) && (legacy == null || legacy === canonical) ? canonical : null;
}
return typeof legacy === "string" && /^[a-f0-9]{40}$/.test(legacy) ? legacy : null;
}
function originalDeletionReceipt(workspace: Workspace, operation: typeof workspaceOperations.$inferSelect | undefined) {
return Boolean(operation && workspace.closedAt && operation.phase === "worktree_cleanup" && operation.status === "succeeded"
&& operation.exitCode === 0 && operation.finishedAt && operation.finishedAt >= operation.startedAt && operation.startedAt >= workspace.closedAt
&& operation.companyId === workspace.companyId && operation.executionWorkspaceId === workspace.id
&& (!operation.issueId || operation.issueId === workspace.sourceIssueId)
&& operation.metadata?.cleanupAction === "branch_delete"
&& operation.metadata.workspaceId === workspace.id && operation.metadata.workspacePath === (workspace.providerRef ?? workspace.cwd)
&& operation.metadata.branchName === workspace.branchName);
}
async function localGit(cwd: string, args: string[]) {
return (await execute("git", ["--no-optional-locks", "-c", "core.fsmonitor=false", "-c", `core.hooksPath=${os.devNull}`,
"-c", "gc.auto=0", "-c", "maintenance.auto=false", "-c", "protocol.allow=never", "-c", "protocol.file.allow=always", "-C", cwd, ...args], {
timeout: 30_000, maxBuffer: 1024 * 1024, env: { PATH: process.env.PATH, LANG: "C", LC_ALL: "C", GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: os.devNull, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0", GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1" },
})).stdout.trim();
}
async function verifiedOriginalLocalSource(workspace: Workspace, sourcePath: string, configuredCwd?: string | null) {
for (const primary of new Set([recordedIsolatedRemoteSource(workspace), ...managedSourcePaths(workspace), configuredCwd].filter((value): value is string => typeof value === "string"))) {
const present = await fs.lstat(primary).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return null; throw error; });
if (present || await physicalPath(primary) !== primary) throw unavailable();
}
const root = resolvePaperclipInstanceRoot(), relative = path.relative(root, sourcePath), full = originalRecordedCommit(workspace);
if (!originalLocalSource(workspace, sourcePath) || !relative || relative === ".." || relative.startsWith(`..${path.sep}`)
|| path.isAbsolute(relative) || path.resolve(sourcePath) !== sourcePath || await fs.realpath(root) !== root
|| typeof full !== "string" || !/^[a-f0-9]{40}$/.test(full)) throw unavailable();
let current = root;
for (const part of relative.split(path.sep)) {
current = path.join(current, part);
const entry = await fs.lstat(current);
if (!entry.isDirectory() || entry.isSymbolicLink()) throw unavailable();
}
const pending = [sourcePath]; let entries = 0;
while (pending.length) {
const directory = await fs.opendir(pending.pop()!);
for await (const entry of directory) {
if (++entries > 10_000) throw unavailable();
const target = path.join(directory.path, entry.name), stat = await fs.lstat(target);
if (stat.isSymbolicLink() || !stat.isDirectory() && !stat.isFile()) throw unavailable();
if (stat.isDirectory()) pending.push(target);
}
}
for (const name of ["objects", "refs"]) {
const entry = await fs.lstat(path.join(sourcePath, name));
if (!entry.isDirectory() || entry.isSymbolicLink()) throw unavailable();
}
const config = await fs.lstat(path.join(sourcePath, "config"));
if (!config.isFile() || config.isSymbolicLink() || config.nlink !== 1 || config.size > 64 * 1024) throw unavailable();
for (const name of ["objects/info/alternates", "objects/info/http-alternates", "refs/replace", "info/grafts", "shallow", "commondir"]) {
const entry = await fs.lstat(path.join(sourcePath, name)).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null; throw error;
});
if (entry) throw unavailable();
}
const keys = (await localGit(sourcePath, ["config", "--local", "--no-includes", "--name-only", "--null", "--list"])).split("\0").filter(Boolean);
if (keys.some(key => !/^(core\.(repositoryformatversion|filemode|bare|logallrefupdates|ignorecase|precomposeunicode)|remote\.origin\.(url|fetch|mirror))$/.test(key))
|| await localGit(sourcePath, ["rev-parse", "--is-bare-repository"]) !== "true"
|| await localGit(sourcePath, ["rev-parse", "--path-format=absolute", "--git-common-dir"]) !== sourcePath
|| await localGit(sourcePath, ["rev-parse", "--show-object-format"]) !== "sha1"
|| await localGit(sourcePath, ["rev-parse", "--verify", `${full}^{commit}`]) !== full) throw unavailable();
const refs = (await localGit(sourcePath, ["for-each-ref", "--format=%(objectname) %(refname)"])).split("\n").filter(Boolean);
if (!refs.length || refs.length > 64 || refs.some(ref => !ref.startsWith(`${full} refs/heads/`) && !ref.startsWith(`${full} refs/tags/`))) throw unavailable();
return sourcePath;
}
function verifiedReceiptObjectSource(workspace: Workspace, sourcePath: string, configuredCwd?: string | null) {
if (originalLocalSource(workspace, sourcePath)) return verifiedOriginalLocalSource(workspace, sourcePath, configuredCwd);
return managedSourcePaths(workspace).includes(sourcePath)
? verifiedManagedGitObjectSource(workspace, sourcePath) : verifiedGitCacheSource(workspace, sourcePath);
}
@@ -102,7 +180,7 @@ export async function inspectArchivedGitMigrationSource(db: Pick<Db, "select">,
const saved = journal?.metadata?.archivedGitSource;
const savedPath = saved && typeof saved === "object" && !Array.isArray(saved)
&& typeof (saved as { sourcePath?: unknown }).sourcePath === "string" ? (saved as { sourcePath: string }).sourcePath : null;
const sourcePath = savedPath ?? (privatePresent && privateSource ? privateSource : projectWorkspace?.cwd ?? managedSourcePaths(workspace)[0]);
let sourcePath = savedPath ?? (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),
@@ -157,9 +235,19 @@ export async function inspectArchivedGitMigrationSource(db: Pick<Db, "select">,
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();
await verifiedReceiptObjectSource(workspace, sourcePath);
const commit = await git(sourcePath, ["rev-parse", "--verify", `${abbreviation}^{commit}`]);
if (!/^[a-f0-9]{40}$/.test(commit)) throw unavailable();
if (!savedPath && managedSourcePaths(workspace).includes(sourcePath)) {
const present = await fs.lstat(sourcePath).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return null; throw error; });
if (!present) {
const original = workspace.repoUrl && /^file:/i.test(workspace.repoUrl) ? localRepositoryPath(workspace.repoUrl) : null;
if (original && projectWorkspace?.repoUrl === workspace.repoUrl && originalDeletionReceipt(workspace, operation)
&& await physicalPath(sourcePath) === sourcePath) sourcePath = original;
}
}
if (originalLocalSource(workspace, sourcePath) && (!projectWorkspace || projectWorkspace.repoUrl !== workspace.repoUrl
|| !originalDeletionReceipt(workspace, operation))) throw unavailable();
await verifiedReceiptObjectSource(workspace, sourcePath, projectWorkspace?.cwd);
const commit = await (originalLocalSource(workspace, sourcePath) ? localGit : git)(sourcePath, ["rev-parse", "--verify", `${abbreviation}^{commit}`]);
if (!/^[a-f0-9]{40}$/.test(commit) || originalLocalSource(workspace, sourcePath) && commit !== originalRecordedCommit(workspace)) throw unavailable();
return { commit, sourcePath, cleanupOperationId: operation.id, recipe: null };
}
@@ -176,6 +264,18 @@ async function authorizeNestedSource(db: Pick<Db, "select">, workspace: Workspac
}
export async function lockArchivedGitMigrationSources(db: Pick<Db, "select">, workspace: Workspace, source: ArchivedGitMigrationSource) {
if (originalLocalSource(workspace, source.sourcePath)) {
const [configured] = await db.select({ repoUrl: projectWorkspaces.repoUrl }).from(projectWorkspaces).where(and(
eq(projectWorkspaces.companyId, workspace.companyId), eq(projectWorkspaces.id, workspace.projectWorkspaceId!),
eq(projectWorkspaces.projectId, workspace.projectId!))).for("share", { noWait: true });
const [receipt] = await db.select().from(workspaceOperations).where(and(eq(workspaceOperations.companyId, workspace.companyId),
eq(workspaceOperations.executionWorkspaceId, workspace.id), eq(workspaceOperations.id, source.cleanupOperationId!))).for("share", { noWait: true });
if (!configured || configured.repoUrl !== workspace.repoUrl || !receipt || !originalDeletionReceipt(workspace, receipt)
|| source.commit !== originalRecordedCommit(workspace)) throw unavailable();
const prefix = `Deleted branch ${workspace.branchName} (was `, output = receipt.stdoutExcerpt?.trim();
if (!output?.startsWith(prefix) || !output.endsWith(").") || !/^[a-f0-9]{7,40}$/.test(output.slice(prefix.length, -2))
|| !source.commit.startsWith(output.slice(prefix.length, -2))) throw unavailable();
}
for (const checkout of [...(source.recipe?.contents?.checkouts ?? [])].sort((a, b) => a.source.id.localeCompare(b.source.id))) {
await authorizeNestedSource(db, workspace, checkout.source, true);
}
@@ -187,7 +287,9 @@ export async function materializeArchivedGitMigrationSource(input: {
}) {
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 original = originalLocalSource(workspace, source.sourcePath), runGit = original ? localGit : git;
if (original && (source.recipe || !source.cleanupOperationId || source.retainedBranch || source.commit !== originalRecordedCommit(workspace))) throw unavailable();
const resolveGitAuth = original ? async () => { throw unavailable(); } : 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;
@@ -199,13 +301,13 @@ export async function materializeArchivedGitMigrationSource(input: {
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"]);
if (source.retainedBranch && (await runGit(verified, ["rev-parse", "--verify", `refs/heads/${source.retainedBranch}^{commit}`])) !== source.commit) throw unavailable();
await fs.mkdir(cwd, { mode: 0o700 }); await runGit(cwd, ["init", "--quiet"]);
if (source.recipe?.restorePoint) await copyVerifiedGitExcludes(verified, cwd).catch(() => { throw unavailable(); });
// 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);
const presentCommit = await runGit(verified, ["cat-file", "-e", `${source.commit}^{commit}`]).then(() => true, () => false);
if (presentCommit) {
await git(cwd, ["fetch", "--no-tags", "--no-recurse-submodules", "--", verified, source.commit]);
await runGit(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
@@ -213,8 +315,8 @@ export async function materializeArchivedGitMigrationSource(input: {
// 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]);
const local = await runGit(verified, ["cat-file", "-e", `${commit}^{commit}`]).then(() => true, () => false);
if (local) await runGit(cwd, ["fetch", "--no-tags", "--no-recurse-submodules", "--", verified, commit]);
else await ensureSavedGitCommit(cwd, workspace.repoUrl, commit, resolveGitAuth);
}
}
@@ -224,12 +326,13 @@ export async function materializeArchivedGitMigrationSource(input: {
}
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 (original) await runGit(cwd, ["cat-file", "-e", `${source.commit}^{commit}`]);
else await ensureSavedGitCommit(cwd, workspace.repoUrl, source.commit, resolveGitAuth);
await runGit(cwd, ["check-ref-format", `refs/heads/${workspace.branchName}`]);
await runGit(cwd, ["checkout", "--detach", source.commit]);
await runGit(cwd, ["update-ref", `refs/heads/${workspace.branchName}`, source.commit]);
await runGit(cwd, ["symbolic-ref", "HEAD", `refs/heads/${workspace.branchName}`]);
await runGit(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, preserveImportedHistory: true });
const approvedLocalSources = new Set<string>();
@@ -251,9 +354,9 @@ export async function materializeArchivedGitMigrationSource(input: {
// 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]);
const tree = await runGit(repository, ["write-tree"]);
await runGit(repository, ["read-tree", "--empty"]);
await runGit(repository, ["read-tree", tree]);
}
return cwd;
}
@@ -0,0 +1,199 @@
import { randomUUID, createHash } from "node:crypto";
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { activityLog, agents, companies, createDb, heartbeatRuns, executionWorkspaceSnapshots, executionWorkspaces, issues, projects, projectWorkspaces, workspaceOperations } from "@paperclipai/db";
import { readRemoteWorkspaceAuthority } from "@paperclipai/shared";
import { startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
import { createStorageService } from "../storage/service.js";
import type { StorageService } from "../storage/types.js";
import { inspectArchivedGitMigrationSource } from "./remote-workspace-migration-git.js";
import { inventoryRemoteWorkspaceMigration, migrateRemoteWorkspace } from "./remote-workspace-migration.js";
import { getWorkspaceSnapshot, loadWorkspaceSnapshotArchive } from "./remote-workspace-snapshot-storage.js";
import { restoreRemoteWorkspaceArchive } from "./remote-workspace-snapshot-archive.js";
import { cleanupExecutionWorkspaceArtifacts } from "./workspace-runtime.js";
import { workspaceOperationService } from "./workspace-operations.js";
import { isolatedRemoteSourcePath } from "./isolated-remote-workspace-source.js";
import { managedSourcePaths } from "./git-workspace-restoration.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
import { mergeExecutionWorkspaceMetadataForPersistence } from "./heartbeat/workspaces.js";
const execute = promisify(execFile);
const git = async (cwd: string, ...args: string[]) => (await execute("git", ["-C", cwd, ...args], { env: { ...process.env,
GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull, GIT_AUTHOR_NAME: "Fixture", GIT_COMMITTER_NAME: "Fixture",
GIT_AUTHOR_EMAIL: "fixture@example.test", GIT_COMMITTER_EMAIL: "fixture@example.test" } })).stdout.trim();
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>, db: ReturnType<typeof createDb>, root: string, storage: StorageService;
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("original-bare-source-"); db = createDb(database.connectionString); }, 90_000);
beforeEach(async () => { root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "original-bare-source-")));
vi.stubEnv("PAPERCLIP_HOME", root); vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default"); vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "61".repeat(32));
storage = createStorageService(createLocalDiskStorageProvider(path.join(root, "objects"))); });
afterEach(async () => { vi.restoreAllMocks(); vi.unstubAllEnvs(); await db.delete(activityLog); await db.delete(workspaceOperations);
await db.delete(executionWorkspaceSnapshots); await db.delete(heartbeatRuns); await db.delete(issues); await db.delete(agents); await db.delete(executionWorkspaces); await db.delete(projectWorkspaces);
await db.delete(projects); await db.delete(companies); await fs.rm(root, { recursive: true, force: true }); });
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await database?.cleanup(); });
async function manifest(base: string, prefix = ""): Promise<Array<{ path: string; mode: number; sha256: string }>> {
const entries = [];
for (const name of (await fs.readdir(path.join(base, prefix))).sort()) {
const relative = path.join(prefix, name), file = path.join(base, relative), stat = await fs.lstat(file);
if (stat.isDirectory()) entries.push(...await manifest(base, relative));
else entries.push({ path: relative, mode: stat.mode & 0o777, sha256: createHash("sha256").update(await fs.readFile(file)).digest("hex") });
}
return entries;
}
async function fixture(options: { legacy?: boolean; verifiedCleanup?: boolean } = {}) {
const companyId = randomUUID(), projectId = randomUUID(), projectWorkspaceId = randomUUID(), issueId = randomUUID(), workspaceId = randomUUID();
const seed = path.join(root, "seed"), original = path.join(resolvePaperclipInstanceRoot(), "acceptance", "original", "main.git");
await fs.mkdir(seed, { recursive: true }); await fs.mkdir(path.dirname(original), { recursive: true });
await git(seed, "init", "-q", "--initial-branch=main");
await fs.writeFile(path.join(seed, "README.md"), "preserve exact original bytes\n");
await fs.writeFile(path.join(seed, "run.sh"), "#!/bin/sh\necho retained\n", { mode: 0o755 });
await git(seed, "add", "."); await git(seed, "commit", "-qm", "retained original");
const commit = await git(seed, "rev-parse", "HEAD"); await git(root, "clone", "--bare", "--no-hardlinks", seed, original);
const repoUrl = pathToFileURL(original).href, base = isolatedRemoteSourcePath(companyId, issueId), branchName = "closed-acceptance";
await fs.mkdir(path.dirname(base), { recursive: true }); await git(root, "clone", "--no-hardlinks", original, base);
await git(base, "remote", "set-url", "origin", repoUrl);
const cwd = path.join(base, ".paperclip", "worktrees", branchName); await git(base, "worktree", "add", "-qb", branchName, cwd);
await db.insert(companies).values({ id: companyId, name: "Original repository", issuePrefix: "BARE" });
await db.insert(projects).values({ id: projectId, companyId, name: "Original source" });
await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, companyId, projectId, name: "Recorded source", cwd: null, repoUrl });
await db.insert(issues).values({ id: issueId, companyId, title: "Closed task", status: "done" });
const [workspace] = await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, projectId, projectWorkspaceId, sourceIssueId: issueId,
mode: "isolated_workspace", strategyType: "git_worktree", providerType: "git_worktree", name: "Closed checkout", status: "archived", closedAt: new Date(),
cwd, providerRef: cwd, branchName, repoUrl, baseRef: "main", metadata: options.legacy
? { createdByRuntime: true, gitBranchOwnershipVersion: 1, baseRefSha: commit, lifecycleGeneration: 1,
isolatedRemoteSource: { schema: "paperclip.isolated-remote-source.v1", companyId, issueId, repoUrl } }
: mergeExecutionWorkspaceMetadataForPersistence({ existingMetadata: { lifecycleGeneration: 1,
isolatedRemoteSource: { schema: "paperclip.isolated-remote-source.v1", companyId, issueId, repoUrl } },
source: "heartbeat", createdByRuntime: true, strategyType: "git_worktree", configSnapshot: null, shouldReuseExisting: false,
baseRef: "main", baseRefSha: commit }) }).returning();
const cleaned = await cleanupExecutionWorkspaceArtifacts({ workspace: workspace!, projectWorkspace: { cwd: base, cleanupCommand: null }, runCleanupCommands: false,
recorder: workspaceOperationService(db).createRecorder({ companyId, executionWorkspaceId: workspaceId, issueId }),
expectedBranchHeadSha: options.verifiedCleanup ? commit : undefined });
expect(cleaned.warnings).toEqual([]); await fs.rm(base, { recursive: true }); await fs.rm(seed, { recursive: true });
expect((await git(original, "rev-list", "--objects", "--no-object-names", "--all")).split("\n")).toHaveLength(4);
return { companyId, projectId, projectWorkspaceId, issueId, workspaceId, original, base, cwd, branchName, repoUrl, commit, workspace: workspace! };
}
it.each([{ label: "canonical metadata ordinary cleanup" }, { label: "legacy metadata ordinary cleanup", legacy: true },
{ label: "legacy metadata verified cleanup", legacy: true, verifiedCleanup: true }, { label: "canonical metadata verified cleanup", verifiedCleanup: true }])
("imports the exact deleted commit with $label and cold-restores its encrypted files", async options => {
const f = await fixture(options), before = await manifest(f.original);
expect((await inspectArchivedGitMigrationSource(db, f.workspace))?.commit).toBe(f.commit);
expect((await inventoryRemoteWorkspaceMigration(db, f)).eligible).toBe(true);
await migrateRemoteWorkspace({ db, storage, ...f, apply: true });
const row = (await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId)))[0]!;
const authority = readRemoteWorkspaceAuthority(row.metadata)!;
expect(authority.source).toMatchObject({ kind: "git", ref: f.commit }); expect(row.status).toBe("archived"); expect(row.cwd).toBe(`/workspaces/${f.workspaceId}`); expect(row.sourceIssueId).toBe(f.issueId);
const reference = (await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! }))!;
const cipher = await storage.getObject(f.companyId, reference.objectKey), parts: Buffer[] = [];
for await (const part of cipher.stream) parts.push(Buffer.from(part));
const encrypted = Buffer.concat(parts);
expect(encrypted.subarray(0, 7).toString()).toBe("PCWSE1\n"); expect(encrypted.includes(Buffer.from("preserve exact original bytes"))).toBe(false);
const archivePath = path.join(root, "downloaded.pcws"), targetDir = path.join(root, "cold-restored");
const parked = path.join(root, "parked-original.git"); await fs.rename(f.original, parked);
try {
expect(await fs.lstat(f.original).catch(() => null)).toBeNull();
await loadWorkspaceSnapshotArchive({ storage, reference, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: reference.manifestDigest });
} finally { await fs.rename(parked, f.original); }
expect(await fs.readFile(path.join(targetDir, "README.md"), "utf8")).toBe("preserve exact original bytes\n");
expect(await fs.readFile(path.join(targetDir, "run.sh"), "utf8")).toBe("#!/bin/sh\necho retained\n");
expect((await fs.lstat(path.join(targetDir, "run.sh"))).mode & 0o777).toBe(0o755);
expect(await git(targetDir, "rev-parse", "HEAD")).toBe(f.commit); expect(await git(targetDir, "log", "--format=%H")).toBe(f.commit);
expect(await manifest(f.original)).toEqual(before); expect(await fs.lstat(f.cwd).catch(() => null)).toBeNull();
});
it.each(["wrong-full-tip", "malformed-base-snapshot", "wrong-project-source", "missing-receipt", "wrong-receipt", "wrong-receipt-path", "reopened", "active-writer", "source-alias", "object-alias", "unsafe-config", "outside-instance", "present-primary", "unknown-primary"])("refuses %s without publishing", async kind => {
const f = await fixture();
if (kind === "wrong-full-tip") await db.update(executionWorkspaces).set({ metadata: { ...f.workspace.metadata, baseRefSha: "f".repeat(40) } }).where(eq(executionWorkspaces.id, f.workspaceId));
if (kind === "malformed-base-snapshot") await db.update(executionWorkspaces).set({ metadata: { ...f.workspace.metadata, baseRefSnapshot: { resolvedSha: "invalid" }, baseRefSha: f.commit } }).where(eq(executionWorkspaces.id, f.workspaceId));
if (kind === "wrong-project-source") await db.update(projectWorkspaces).set({ repoUrl: "https://example.invalid/other.git" }).where(eq(projectWorkspaces.id, f.projectWorkspaceId));
const operations = await db.select().from(workspaceOperations).where(eq(workspaceOperations.executionWorkspaceId, f.workspaceId));
const deletion = operations.find(row => row.metadata?.cleanupAction === "branch_delete")!;
if (kind === "missing-receipt") await db.delete(workspaceOperations).where(eq(workspaceOperations.id, deletion.id));
if (kind === "wrong-receipt") await db.update(workspaceOperations).set({ stdoutExcerpt: "Deleted branch other (was 1234567)." }).where(eq(workspaceOperations.id, deletion.id));
if (kind === "wrong-receipt-path") await db.update(workspaceOperations).set({ metadata: { ...deletion.metadata, workspacePath: f.original } }).where(eq(workspaceOperations.id, deletion.id));
if (kind === "reopened") await db.update(executionWorkspaces).set({ status: "active", closedAt: null }).where(eq(executionWorkspaces.id, f.workspaceId));
if (kind === "active-writer") { const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Existing writer" }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: agent!.id, issueId: f.issueId, status: "running" }).returning();
await db.update(issues).set({ executionRunId: run!.id }).where(eq(issues.id, f.issueId)); }
if (kind === "source-alias") { const original = f.original + ".retained"; await fs.rename(f.original, original); await fs.symlink(original, f.original); }
if (kind === "object-alias") { const objects = path.join(f.original, "objects"), retained = path.join(root, "retained-objects"); await fs.rename(objects, retained); await fs.symlink(retained, objects); }
if (kind === "unsafe-config") await git(f.original, "config", "uploadpack.packObjectsHook", "exit 97");
if (kind === "outside-instance") { const original = path.join(root, "outside.git"); await fs.rename(f.original, original); const repoUrl = pathToFileURL(original).href;
await db.update(projectWorkspaces).set({ repoUrl }).where(eq(projectWorkspaces.id, f.projectWorkspaceId));
await db.update(executionWorkspaces).set({ repoUrl, metadata: { ...f.workspace.metadata, isolatedRemoteSource: { schema: "paperclip.isolated-remote-source.v1", companyId: f.companyId, issueId: f.issueId, repoUrl } } }).where(eq(executionWorkspaces.id, f.workspaceId)); }
const primary = managedSourcePaths(f.workspace)[0]!;
if (kind === "present-primary") { await fs.mkdir(path.join(primary, ".git"), { recursive: true }); await fs.writeFile(path.join(primary, "keep.txt"), "unknown primary files"); }
if (kind === "unknown-primary") { const stat = fs.lstat.bind(fs); vi.spyOn(fs, "lstat").mockImplementation(async (...args) => { if (String(args[0]) === primary) throw Object.assign(new Error("not readable"), { code: "EACCES" }); return stat(...args); }); }
const put = vi.spyOn(storage, "putFile");
expect((await inventoryRemoteWorkspaceMigration(db, f)).eligible).toBe(false);
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow();
expect(put).not.toHaveBeenCalled(); expect(readRemoteWorkspaceAuthority((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId)))[0]!.metadata)).toBeNull();
});
it.each(["changed-ref", "changed-source-row", "changed-cleanup"])("preserves the uploaded object without authority when %s changes before publication", async kind => {
const f = await fixture(), put = storage.putFile.bind(storage); let changed = false;
storage.putFile = async input => { const result = await put(input); if (!changed) { changed = true;
if (kind === "changed-ref") { const tree = await git(f.original, "rev-parse", f.commit + "^{tree}");
const newer = await git(f.original, "commit-tree", tree, "-p", f.commit, "-m", "newer ref"); await git(f.original, "update-ref", "refs/heads/main", newer); }
if (kind === "changed-source-row") await db.update(projectWorkspaces).set({ repoUrl: "https://example.invalid/changed.git" }).where(eq(projectWorkspaces.id, f.projectWorkspaceId));
if (kind === "changed-cleanup") { const rows = await db.select().from(workspaceOperations).where(eq(workspaceOperations.executionWorkspaceId, f.workspaceId));
const deletion = rows.find(row => row.metadata?.cleanupAction === "branch_delete")!; await db.update(workspaceOperations).set({ stdoutExcerpt: "Changed receipt" }).where(eq(workspaceOperations.id, deletion.id)); }
} return result; };
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow(); expect(changed).toBe(true);
const row = (await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId)))[0]!; expect(readRemoteWorkspaceAuthority(row.metadata)).toBeNull();
const snapshots = await db.select().from(executionWorkspaceSnapshots).where(eq(executionWorkspaceSnapshots.executionWorkspaceId, f.workspaceId));
expect(snapshots).toHaveLength(1); expect(snapshots[0]!.status).toBe("uploaded"); expect(snapshots[0]!.objectKey).toBeTruthy();
});
it("reuses the same authenticated uploaded snapshot after losing the original local-source upload reply", async () => {
const f = await fixture(), put = storage.putFile.bind(storage); let lost = false;
storage.putFile = async input => { const result = await put(input); if (!lost) { lost = true; throw new Error("upload_ack_lost"); } return result; };
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("upload_ack_lost");
const pending = (await db.select().from(executionWorkspaceSnapshots).where(eq(executionWorkspaceSnapshots.executionWorkspaceId, f.workspaceId)))[0]!;
expect(pending.status).toBe("pending");
const result = await migrateRemoteWorkspace({ db, storage, ...f, apply: true }); expect(result.applied).toBe(true);
const snapshots = await db.select().from(executionWorkspaceSnapshots).where(eq(executionWorkspaceSnapshots.executionWorkspaceId, f.workspaceId));
expect(snapshots).toHaveLength(1); expect(snapshots[0]!.id).toBe(pending.id); expect(snapshots[0]!.sourceOperationId).toBe(pending.sourceOperationId);
const reference = (await getWorkspaceSnapshot(db, { ...f, snapshotId: pending.id }))!, archivePath = path.join(root, "retry.pcws"), targetDir = path.join(root, "retry-restored");
await loadWorkspaceSnapshotArchive({ storage, reference, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: reference.manifestDigest });
expect(await git(targetDir, "rev-parse", "HEAD")).toBe(f.commit);
expect(await fs.readFile(path.join(targetDir, "README.md"), "utf8")).toBe("preserve exact original bytes\n");
});
it.each(["private", "generated", "hashed"].flatMap(primary => ["directory", "alias", "unreadable"].flatMap(state => ["retry", "publication"].map(boundary => ({ primary, state, boundary })))))
("refuses returned primary $primary $state at $boundary without losing either copy", async ({ primary, state, boundary }) => {
const f = await fixture(), put = storage.putFile.bind(storage);
const target = primary === "private" ? f.base : managedSourcePaths(f.workspace)[primary === "generated" ? 0 : 1]!;
const retained = state === "alias" ? path.join(root, "returned-primary") : target;
const restorePrimary = async () => {
await fs.mkdir(path.dirname(retained), { recursive: true }); await git(root, "clone", "--no-hardlinks", f.original, retained);
await fs.writeFile(path.join(retained, "retained-edit.txt"), "returned primary work must survive\n");
if (state === "alias") { await fs.mkdir(path.dirname(target), { recursive: true }); await fs.symlink(retained, target); }
if (state === "unreadable") { const stat = fs.lstat.bind(fs); vi.spyOn(fs, "lstat").mockImplementation(async (...args) => {
if (String(args[0]) === target) throw Object.assign(new Error("primary unreadable"), { code: "EACCES" }); return stat(...args);
}); }
};
let uploaded = false, uploadedKey: string | null = null, uploadedSha256: string | null = null;
storage.putFile = async input => { const result = await put(input); uploadedKey = result.objectKey; uploadedSha256 = result.sha256; if (!uploaded) { uploaded = true;
if (boundary === "retry") throw new Error("upload_ack_lost"); await restorePrimary();
} return result; };
if (boundary === "retry") { await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("upload_ack_lost"); await restorePrimary(); }
const snapshotsBefore = await db.select().from(executionWorkspaceSnapshots).where(eq(executionWorkspaceSnapshots.executionWorkspaceId, f.workspaceId));
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow();
const row = (await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId)))[0]!;
expect(readRemoteWorkspaceAuthority(row.metadata)).toBeNull();
expect(await fs.readFile(path.join(retained, "retained-edit.txt"), "utf8")).toBe("returned primary work must survive\n");
const snapshots = await db.select().from(executionWorkspaceSnapshots).where(eq(executionWorkspaceSnapshots.executionWorkspaceId, f.workspaceId));
expect(snapshots).toHaveLength(1); if (boundary === "retry") expect(snapshots[0]!.id).toBe(snapshotsBefore[0]!.id);
expect(uploadedKey).toBeTruthy(); if (boundary === "retry") expect(snapshots[0]!.objectKey).toBeNull(); else expect(snapshots[0]!.objectKey).toBe(uploadedKey);
const cipher = await storage.getObject(f.companyId, uploadedKey!), hash = createHash("sha256"); let bytes = 0;
for await (const chunk of cipher.stream) { bytes += Buffer.byteLength(chunk); hash.update(chunk); }
expect(bytes).toBeGreaterThan(0); expect(hash.digest("hex")).toBe(uploadedSha256);
expect(await git(f.original, "rev-parse", "HEAD")).toBe(f.commit);
});