mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 12:33:05 +01:00
## 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>
152 lines
10 KiB
TypeScript
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;
|
|
}
|