Files
PaperClipAI/server/src/services/remote-workspace-migration.test.ts
T
DottaandPaperclip d5c4f7dd02 fix: preserve shared Git sources during online workspace migration (#19688)
## Thinking Path

> - Paperclip preserves the work produced by its agents.
> - Remote workspace migration must preserve legacy Git history and
local edits.
> - Linked checkouts can share administration and objects with other
tasks.
> - A task lock alone cannot exclude writers of a shared or borrowed
store.
> - The online source owner must derive and retain the full physical
ownership closure.
> - This change reuses task, project, lifecycle and finalizer locks for
that closure.
> - Idle legacy Git work can then migrate while unrelated remote turns
continue.

## Linked Issues or Issue Description

Refs: #19642.

**What happened?**

Online migration keeps legacy Git sources blocked because several task
checkouts can share a project base or a private Git cache. A read-only
source observation found 160 pending Git records, 120 distinct paths and
nine common stores. Its largest project cohort contained 126 records and
110 task owners. An ordinary-directory fence cannot preserve these
sources by itself.

**Expected behavior**

Derive the physical source owners from stable Git metadata. Hold their
existing source and lifecycle keys through publication. Retain all
history and recovery obligations. Reject foreign aliases, unknown stores
and changed sources. Let unrelated object-backed work continue.

**Steps to reproduce**

1. Create a managed project checkout and linked task worktrees.
2. Keep a settled prepared recovery source on a sibling task.
3. Keep unrelated remote work running.
4. Request an online import of an idle leaf checkout.
5. The existing owner keeps the shared source blocked.

**Paperclip version or commit**

This branch builds on merged #19642 (`7ac99718a4`) and fresh master
`fb3cf1a072`. It adds the Git closure. It does not add a writers-stopped
shortcut.

**Deployment mode**

Hosted native Runner with remote workspace storage. Local qualification
uses owned synthetic Git repositories, disposable PostgreSQL databases
and the actual archive and storage owners.

## What Changed

- Read bounded stable Git pointers, common-directory metadata and local
alternates. Derive the complete physical source ownership cohort.
Preserve all object stores.
- Retain common and borrowed store owners in preparation, lifecycle,
runtime, expiration and actual reopen paths. Keep company boundaries and
the existing 128-key bound.
- Hold exact sibling native recovery proofs under their existing
physical finalizer keys. Recheck descriptors, allocations, recovery
actions and source identities before capture and publication. Preserve
original states and warnings.
- Require leaf-first nested imports. Reuse the existing immutable child
download, restore and source comparison before parent exclusion.
- Bind the self-contained archive owner to the held Git namespaces. Keep
its three walks, history normalization, fsck, bytes and digest format.
Reject bare, detached, network, unknown or changed Git storage.
- Check both foreign path fields. Keep foreign metadata private.
Document the proof, bounds, source observation and remaining eligibility
limits.

## Verification

- Final revision `2934e41c923b93e16508b978c849b747c86be645`: all checks
are terminal, with 56 successful and four skipped, zero failures and
zero pending. The [required CI
aggregate](https://github.com/paperclipai/paperclip/actions/runs/42326464408/job/128266279210)
passed. Build, types, all server/Runner/serialized suites, end-to-end
aggregate and Snyk passed. [Fresh exact-head Greptile
review](https://github.com/paperclipai/paperclip/pull/19688#issuecomment-6699760104)
is 5/5 with no open threads. GitHub reports MERGEABLE and CLEAN.

- Review then found two correctness gaps: an ordinary managed directory
inside an unrelated host repository was blocked, and a Git parent could
miss a completed nested operation after its child snapshot became
missing or deleted. Three actual-owner regressions reproduced both gaps
in 9.70 seconds. The corrected owner passes 12 focused cases in 17.08
seconds. It imports and restores only the ordinary root bytes, preserves
the outer repository, rejects both missing/deleted child receipts before
a parent journal, and retains valid/changed child coverage and
private-cache contention. Its wrapper exited 0 and cleanup completed.
The first bootstrap attempt failed before tests ran because the host
exhausted its 32 shared-memory segment limit; no shared services were
stopped and no pass is claimed for that attempt. The final qualified
source/test type graph, module boundaries and diff checks pass.
- Receipt scans keep canonical path resolution. A lexical SQL prefix can
omit children reached through symlinked ancestors. The observed full
metadata closure completed in 1.17 seconds; no measured bottleneck
justifies weakening that alias proof. Writer admission still rejects
uncertain Git ownership before mutation. Expiration returns its typed
skip, and reopen returns its structured failure. Runtime and lifecycle
do not bypass an unknown source. No broad error-contract change was
added.

- Full CI at `720104ea975e` exposed five existing boundary cases across
three server shards. Four exact cases reproduced locally as failures in
25.98 seconds. Reopen now preserves its structured failure when source
inspection rejects the path, without reconstructing it. Runtime
recognizes only the exact regular ignore-only `.git/info/exclude`
fragment; migration still rejects that fragment. The original
arbitrary-path and file-URL import/delete-host/resume fixtures keep both
supported input forms and use the genuine offline setup path after their
sole writer finishes. The production mode gate is unchanged. A new
real-owner control proves the offline flag still rejects a running
legacy database writer.
- The final focused run passed all ten cases across four suites in 22.75
seconds, including all five CI failures, invalid-pointer admission,
fragment rejection, offline writer exclusion and actual nested reopen
lock ordering. Its wrapper exited 0 and cleanup completed. The known
invalid-pointer case remains fail-closed; the separate unexpected-I/O
diagnostic retains safe error metadata without command text or
credentials. Existing Git stderr redaction coverage remains intact. The
final metadata reader suite passed 20 cases in 2.68 seconds. The
qualified server/test source type graph, module boundaries and diff
checks pass. No production assertions or timeout defaults were weakened.
Fresh CI and review are required for the repair revision.

- A later review found that the ordinary early return still used only
one foreign path field. The actual ordinary providerRef-only regression
failed as incorrectly eligible. One source-path owner now enumerates
both local locators for company/foreign ordinary and Git scans, layout
checks, early admission, lifecycle/reopen/runtime and
expiration/move/preparation retention. Capture and display keep their
original preferred path. The actual owner canary passed 49 cases across
two suites in 109.15 seconds. It covers
cwd-only/ref-only/different/symlink locators, same-company mismatches,
foreign owners, lifecycle ordering, Git/recovery imports and
expiration/reopen. Three selected-locator fallback cases also failed
before their guard; the final eight-case control passed in 31.05
seconds, including the configured alias and valid import/private-child
controls. All wrappers exited 0 and completed cleanup. The
metadata/locator suite passed 19 cases. The final source/test type
graph, module boundaries and diff checks pass.

- Review found two P1 gaps. Both configured-directory symlink cases
failed before canonicalization. The actual parent import lost an
already-imported child's unique private cache and failed its archive
fence instead of retaining that owner. The corrected real-owner canary
passed four cases in 20.78 seconds: configured symlink runtime
provisioning, private-cache contention before the parent journal,
successful parent publication after release, and unchanged/changed child
coverage. The metadata suite passed 15 cases in 4.14 seconds. Both
wrappers exited 0 and cleanup completed.
- A fresh source read-only observation found 160 pending Git records and
nine common stores. An additional exact receipt query found 1,243
completed migration receipts and 557 nested source paths under pending
Git roots. All 126 records in the largest project were present in the
captured Git metadata set. For all 110 present candidate workspace
records, the derived closure fits the unchanged 128-key cap. The worst
case needs 110 issue keys and one project key, with 17 keys of headroom.
Its 173 imported children introduce no physical issue owner outside
those 110 logical task owners. This checks the observed key bound only.
It does not claim migration eligibility or a coherent snapshot.
- The initial stacked CI did not run four Runner test lanes: trusted
master passed new --package-lane/--native-server options to the old base
lane script. The branch is now rebased onto actual merged master and
retargeted before synchronization. It includes the canonical current
lane parser without an unrelated Runner patch. Fresh new-head CI is
required.

- The actual importer/source owner run passed 36 cases across three
suites in 187.75 seconds. It covers sibling prepared recovery, unrelated
running remote work, common-cache contention, changed
coordinator/allocation/path state during upload, exact nested coverage,
dirty unpushed work, and existing expiration/reopen owners. Wrapper exit
0 and cleanup completed.
- A separate previously hanging lifecycle-worker restore passed in 17.33
seconds with the unchanged 15-second test-body limit. An earlier run
timed out in three expiration/reopen cases and blocked cleanup; its
wrapper later exited 1. These failures remain recorded. They are not
counted as passes.
- Two boundary controls first failed: a foreign providerRef-only alias
was incorrectly eligible, and actual reopen did not admit its distinct
cache owner at the initial source boundary. The narrow guards now
inspect both foreign locators and derive physical Git keys before the
raw lifecycle transaction. The foreign alias and existing real nested
reopen contention passed. The private-cache control first retained
contradictory recorded source provenance and stayed fail-closed after
release. The corrected present legacy-worktree fixture passed in 12.61
seconds. These failed attempts remain recorded; they are not passes.
- The metadata reader and serialized/local archive controls use actual
Git repositories. They compare archive bytes and digests, remove every
original source, restore staged and unstaged work, and run fsck. The
metadata reader passed 13 cases in 2.85 seconds. The local/serialized
archive controls passed nine cases in 9.27 seconds. All processes exited
0. An additional actual importer control changed a relative commondir
pointer to the absolute name of the same store during upload.
Publication was rejected, the uploaded journal remained available, and
original files stayed intact (one case, 5.86 seconds; wrapper exit 0 and
cleanup complete).
- The final rebased full local server/test type graph passes with
db/shared/Runner source aliases because reused dependency declarations
predate new master exports. The first rebased attempt reported those
stale declaration fields/tables, and is not counted as a pass. Module
boundaries and diff checks pass. Standard package builds, types and
broad regression checks remain required in CI.
- No hosted import, admission hold, cancellation, provider mutation,
source filesystem write or shared-cache Git command was performed for
this change. The source observation is read-only and is not a locked
eligibility proof.

## Risks

- A missing writer owner could make physical source exclusion
incomplete. Existing mutation owners retain the same
company/task/project keys, and uncertain metadata fails closed.
- A project can contain a large cohort. Limits remain finite. A bound
failure keeps the source blocked.
- Bare, detached, unowned or network Git stores remain blocked online.
Missing archived sources retain their existing authenticated
reconstruction path.
- Exact recovery proofs preserve authority and obligations. They do not
infer provider termination or mark a prepared descriptor finalized.
- This change does not claim all observed Git records are immediately
eligible. Existing preparation, provider, recovery, runtime and
source-change blockers remain.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, tool use and code execution.
The host does not expose a more specific backend model ID or
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-06 20:08:19 -06:00

1265 lines
106 KiB
TypeScript

import { randomUUID } 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 { 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 { and, eq } from "drizzle-orm";
import { activityLog, agents, environmentLeases, heartbeatRuns, nativeRunFinalizations, nativeRunResults, completionContracts, applyPendingMigrations, closeRegisteredClients, companies, createDb, executionWorkspaceSnapshots,
executionWorkspaceRepositories, executionWorkspaceRuntimeLeases, executionWorkspaces, issueRecoveryActions, issues, projects, projectWorkspaces, workspaceOperations, workspaceRuntimeServices } from "@paperclipai/db";
import { readRemoteWorkspaceAuthority } from "@paperclipai/shared";
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
import { createStorageService } from "../storage/service.js";
import type { StorageService } from "../storage/types.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
import { migrateWorkspaceTrees } from "./remote-workspace-migration-batch.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 { captureTaskDirectorySnapshot, removeTaskDirectoryTree } from "./isolated-task-directory-snapshot.js";
import { captureRestorePoint, sealRestorePoint } from "./git-restore-point.js";
import type { TaskDirectoryRestorePointRef } from "./isolated-task-directory-retention.js";
import { resolveManagedProjectWorkspaceDir, resolvePaperclipInstanceRoot } from "../home-paths.js";
import { cleanupExecutionWorkspaceArtifacts } from "./workspace-runtime.js";
import { workspaceOperationService } from "./workspace-operations.js";
import { resumeNativeWorkspaceSync } from "./native-runtime/native-workspace-sync.js";
import { claimNativeSessionResumptions } from "./native-runtime/native-finalization-reconciler.js";
import { claimNativeRestartRecoveries } from "./native-runtime/native-restart-recovery.js";
import { attemptNativeWorkspaceFinalization } from "./native-runtime/native-workspace-finalizer.js";
import { addNestedRepositoryWorktree, nestedRepositoryBaseCandidates } from "./nested-repository-worktrees.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,
GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull, GIT_AUTHOR_NAME: "Migration", GIT_COMMITTER_NAME: "Migration",
GIT_AUTHOR_EMAIL: "migration@example.test", GIT_COMMITTER_EMAIL: "migration@example.test" } })).stdout.trim();
const url = process.env.PAPERCLIP_TEST_DATABASE_URL;
if (url && !/^remote_workspace_[a-z_]*test$/.test(new URL(url).pathname.slice(1))) throw new Error("Disposable remote_workspace_*test database required");
const support = url ? { supported: true } : await getEmbeddedPostgresTestSupport();
describe.skipIf(!support.supported)("remote workspace migration", () => {
let db: ReturnType<typeof createDb>, root: string, storage: StorageService;
const servers: Server[] = [];
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
beforeAll(async () => {
if (url) { await applyPendingMigrations(url); db = createDb(url); }
else { temporary = await startEmbeddedPostgresTestDatabase("workspace-migration-"); db = createDb(temporary.connectionString); }
}, 90_000);
afterAll(async () => { if (url) await closeRegisteredClients(url, { scope: "database" }); else await temporary?.cleanup(); });
beforeEach(async () => { vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "61".repeat(32));
root = await fs.mkdtemp(path.join(os.tmpdir(), "pc-migration-test-")); storage = createStorageService(createLocalDiskStorageProvider(path.join(root, "objects"))); });
afterEach(async () => {
await Promise.all(servers.splice(0).map(server => new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))));
vi.unstubAllEnvs(); await db.delete(activityLog); await db.delete(workspaceOperations); await db.delete(executionWorkspaceSnapshots);
await db.delete(workspaceRuntimeServices); await db.delete(executionWorkspaceRuntimeLeases);
await db.delete(nativeRunFinalizations); await db.delete(nativeRunResults); await db.delete(heartbeatRuns); await db.delete(completionContracts);
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 });
});
async function seed() {
const companyId = randomUUID(), workspaceId = randomUUID(), cwd = path.join(root, "original"); await fs.mkdir(cwd);
await fs.writeFile(path.join(cwd, "saved.txt"), "original saved work\n");
await db.insert(companies).values({ id: companyId, name: "Migration", issuePrefix: `M${companyId.slice(0, 6)}` });
const [issue] = await db.insert(issues).values({ companyId, title: "Existing task" }).returning();
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, sourceIssueId: issue!.id, mode: "isolated_workspace",
strategyType: "project_primary", providerType: "local_fs", name: "Existing workspace", cwd, providerRef: cwd, metadata: { retainedNote: "keep" } });
await db.update(issues).set({ executionWorkspaceId: workspaceId }).where(eq(issues.id, issue!.id));
return { companyId, workspaceId, issueId: issue!.id, cwd };
}
async function workspace(id: string) { return (await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, id)))[0]!; }
async function pendingRepository(f: Awaited<ReturnType<typeof seed>>) {
const [repository] = await db.insert(executionWorkspaceRepositories).values({ companyId: f.companyId,
executionWorkspaceId: f.workspaceId, requestedByIssueId: f.issueId, repositoryIdentity: "https://example.invalid/pending.git",
repoUrl: "https://example.invalid/pending.git", relativePath: ".paperclip-repositories/pending", requestedRef: "HEAD",
requestKey: "preserve-pending", requestKeys: ["preserve-pending"] }).returning();
return repository!;
}
it("preserves an unmaterialized pending repository request during offline migration", async () => {
const f = await seed(), repository = await pendingRepository(f);
vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
await migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true });
const authority = readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)!;
expect(authority.source).toEqual({ kind: "empty" });
expect(await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.id, repository.id)))
.toEqual([repository]);
await fs.rm(f.cwd, { recursive: true });
const reference = await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! });
const archivePath = path.join(root, "pending.pcws"), targetDir = path.join(root, "restored-pending");
await loadWorkspaceSnapshotArchive({ storage, reference: reference!, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: reference!.manifestDigest });
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("original saved work\n");
expect(await fs.lstat(path.join(targetDir, repository.relativePath)).catch(() => null)).toBeNull();
});
it("rejects publication if a pending repository becomes ready during capture", async () => {
const f = await seed(), repository = await pendingRepository(f);
vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
const putFile = storage.putFile.bind(storage);
vi.spyOn(storage, "putFile").mockImplementationOnce(async input => {
const result = await putFile(input);
await db.update(executionWorkspaceRepositories).set({ state: "ready", pinnedCommit: "b".repeat(40) })
.where(eq(executionWorkspaceRepositories.id, repository.id));
return result;
});
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true }))
.rejects.toThrow("remote_workspace_migration_source_changed");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("original saved work\n");
});
it.each(["online", "preparing", "needs_repair", "pinned", "branched", "failure", "directory", "symlink", "parent_symlink", "parent_file", "escaping_path"] as const)(
"keeps an unresolved repository request blocked when %s", async state => {
const f = await seed(), repository = await pendingRepository(f);
vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
if (state === "preparing" || state === "needs_repair") await db.update(executionWorkspaceRepositories)
.set({ state }).where(eq(executionWorkspaceRepositories.id, repository.id));
if (state === "pinned") await db.update(executionWorkspaceRepositories).set({ pinnedCommit: "a".repeat(40) })
.where(eq(executionWorkspaceRepositories.id, repository.id));
if (state === "branched") await db.update(executionWorkspaceRepositories).set({ branchName: "saved" })
.where(eq(executionWorkspaceRepositories.id, repository.id));
if (state === "failure") await db.update(executionWorkspaceRepositories).set({ failureCode: "repository_preparation_failed" })
.where(eq(executionWorkspaceRepositories.id, repository.id));
if (state === "escaping_path") await db.update(executionWorkspaceRepositories).set({ relativePath: "../absent" })
.where(eq(executionWorkspaceRepositories.id, repository.id));
if (state === "parent_symlink") await fs.symlink("missing-parent", path.dirname(path.join(f.cwd, repository.relativePath)));
if (state === "parent_file") await fs.writeFile(path.dirname(path.join(f.cwd, repository.relativePath)), "preserve parent file\n");
if (state === "directory" || state === "symlink") {
const target = path.join(f.cwd, repository.relativePath); await fs.mkdir(path.dirname(target), { recursive: true });
if (state === "directory") { await fs.mkdir(target); await fs.writeFile(path.join(target, "partial.txt"), "keep partial work\n"); }
else await fs.symlink("missing-target", target);
}
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: state !== "online" }))
.rejects.toThrow("repository_preparation_unresolved");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("original saved work\n");
if (state === "directory") expect(await fs.readFile(path.join(f.cwd, repository.relativePath, "partial.txt"), "utf8"))
.toBe("keep partial work\n");
});
async function nestedFixture() {
const parent = await seed(), workspaceId = randomUUID();
const cwd = path.join(parent.cwd, ".paperclip", "worktrees", "child");
await fs.mkdir(cwd, { recursive: true }); await fs.writeFile(path.join(cwd, "child.txt"), "saved child edits\n");
const [issue] = await db.insert(issues).values({ companyId: parent.companyId, title: "Nested task" }).returning();
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId: parent.companyId, sourceIssueId: issue!.id,
name: "Child workspace", mode: "isolated_workspace", strategyType: "project_primary", providerType: "local_fs", cwd, providerRef: cwd });
await db.update(issues).set({ executionWorkspaceId: workspaceId }).where(eq(issues.id, issue!.id));
return { parent, child: { companyId: parent.companyId, workspaceId, issueId: issue!.id, cwd } };
}
it("requires offline maintenance for overlapping physical workspace paths", async () => {
const { parent, child } = await nestedFixture();
await expect(migrateRemoteWorkspace({ db, storage, ...child, apply: true })).rejects.toThrow("overlapping_workspace_requires_maintenance");
await expect(migrateRemoteWorkspace({ db, storage, ...parent, apply: true })).rejects.toThrow("overlapping_workspace_requires_maintenance");
expect(await fs.readFile(path.join(child.cwd, "child.txt"), "utf8")).toBe("saved child edits\n");
});
it("imports nested task files before their parent and retains independently restorable copies", async () => {
const { parent, child } = await nestedFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
await migrateRemoteWorkspace({ db, storage, ...child, apply: true, writersStopped: true });
await migrateRemoteWorkspace({ db, storage, ...parent, apply: true, writersStopped: true });
await fs.rm(parent.cwd, { recursive: true });
for (const [name, fixture] of [["parent", parent], ["child", child]] as const) {
const authority = readRemoteWorkspaceAuthority((await workspace(fixture.workspaceId)).metadata)!;
const ref = await getWorkspaceSnapshot(db, { ...fixture, snapshotId: authority.snapshotId! });
const archivePath = path.join(root, `${name}.pcws`), targetDir = path.join(root, `restored-${name}`);
await loadWorkspaceSnapshotArchive({ storage, reference: ref!, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref!.manifestDigest });
expect(await fs.readFile(path.join(targetDir, name === "parent" ? "saved.txt" : "child.txt"), "utf8"))
.toBe(name === "parent" ? "original saved work\n" : "saved child edits\n");
if (name === "parent") {
expect(await fs.stat(path.join(targetDir, ".paperclip", "worktrees", "child")).catch(() => null)).toBeNull();
const [snapshot] = await db.select().from(executionWorkspaceSnapshots).where(eq(executionWorkspaceSnapshots.id, ref!.id));
expect(snapshot.metadata?.importedWorkspaceChildren).toEqual([expect.objectContaining({ workspaceId: child.workspaceId,
relativePath: ".paperclip/worktrees/child" })]);
}
}
});
it("requires the offline entry point even when a caller asserts writers stopped", async () => {
const { parent } = await nestedFixture();
await expect(migrateRemoteWorkspace({ db, storage, ...parent, apply: true, writersStopped: true })).rejects.toThrow("offline_maintenance_required");
await expect(migrateWorkspaceTrees(db, { storage, signal: new AbortController().signal, phase: () => {} })).rejects.toThrow("offline_maintenance_required");
});
it.each(["running", "queued"])("refuses offline archived imports while any execution is running or requeued (%s)", async status => {
const f = await seed(); await retainDirectory(f); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Unrelated writer" }).returning();
await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: agent!.id, status, startedAt: status === "queued" ? new Date() : null });
await expect(migrateWorkspaceTrees(db, { storage, signal: new AbortController().signal, phase: () => {} }))
.rejects.toThrow("active_execution_requires_drain");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
});
it.each(["startedAt", "runtimeModeResolvedAt", "processPid", "processStartedAt", "runnerInstanceId", "nativeSessionId", "nativePhase", "controllerLeaseExpiresAt", "executionStage", "checkpoint", "finalizer", "lease"])("keeps queued execution with %s as an offline blocker", async evidence => {
const f = await seed(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Retained execution" }).returning();
const patch = evidence.endsWith("At") ? { [evidence]: new Date() }
: evidence === "processPid" ? { processPid: 123 }
: evidence.endsWith("Id") ? { [evidence]: randomUUID() }
: evidence === "nativePhase" ? { nativePhase: "observed" }
: evidence === "executionStage" ? { executionStage: "dispatch" }
: evidence === "checkpoint" ? { runnerProfileJson: { sessionCheckpoint: {} } } : {};
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: agent!.id,
nativeIssueId: evidence === "finalizer" ? f.issueId : null, status: "queued", ...patch }).returning();
if (evidence === "finalizer") await db.insert(nativeRunFinalizations).values({ companyId: f.companyId,
issueId: f.issueId, runId: run!.id, phase: "observed" });
if (evidence === "lease") await db.insert(environmentLeases).values({ companyId: f.companyId, heartbeatRunId: run!.id });
await expect(migrateWorkspaceTrees(db, { storage, signal: new AbortController().signal, phase: () => {} }))
.rejects.toThrow("active_execution_requires_drain");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
});
it("preserves an unstarted queued turn during offline import while live migration still refuses it", async () => {
const f = await seed();
const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Queued turn" }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: agent!.id,
issueId: f.issueId, status: "queued", contextSnapshot: { issueId: f.issueId, executionWorkspaceId: f.workspaceId } }).returning();
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("active_execution");
vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
await migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true });
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run!.id)))[0]).toEqual(run);
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).not.toBeNull();
});
it.each([["stranded_assigned_issue", "stranded_assigned_issue"], ["configuration_validation", "configuration_incomplete"],
["missing_disposition", "successful_run_missing_state"], ["deliberate_wait_without_target", "deliberate_wait_without_target"]])("preserves a task-only %s warning through offline file migration", async (kind, cause) => {
const f = await seed();
const [action] = await db.insert(issueRecoveryActions).values({ companyId: f.companyId, sourceIssueId: f.issueId,
kind, cause, fingerprint: randomUUID(), nextAction: "Keep the existing task warning", evidence: { latestRunStatus: "failed" } }).returning();
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("workspace_recovery_unresolved");
vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
await migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true });
expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, action!.id)))[0]).toEqual(action);
const authority = readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)!;
const ref = await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! });
const archivePath = path.join(root, "task-warning.pcws"), targetDir = path.join(root, "restored-task-warning");
await fs.rm(f.cwd, { recursive: true });
await loadWorkspaceSnapshotArchive({ storage, reference: ref!, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref!.manifestDigest });
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("original saved work\n");
});
it.each(["workspaceRestoreRecovery", "workspaceRecovery", "historicalWorkspaceSource", "systemRecovery", "nativeFailureBlock", "export-cause", "unknown-kind", "workspace_validation", "active_run_watchdog"])("retains unresolved file or execution recovery as an offline blocker (%s)", async mode => {
const f = await seed(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
await db.insert(issueRecoveryActions).values({ companyId: f.companyId, sourceIssueId: f.issueId,
kind: mode === "unknown-kind" ? "future_recovery" : ["workspace_validation", "active_run_watchdog"].includes(mode) ? mode : "configuration_validation",
cause: mode === "export-cause" ? "native_workspace_sync_out_unrecoverable" : mode === "workspace_validation" ? "workspace_validation_failed"
: mode === "active_run_watchdog" ? "native_session_interrupted" : "configuration_incomplete",
fingerprint: randomUUID(), nextAction: "Preserve unresolved recovery", evidence: ["export-cause", "unknown-kind", "workspace_validation", "active_run_watchdog"].includes(mode) ? {}
: { [mode]: mode === "systemRecovery" ? { state: "running" } : { schema: "unverified" } } });
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true })).rejects.toThrow("workspace_recovery_unresolved");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
});
it("rechecks a task warning that becomes file recovery during upload", async () => {
const f = await seed(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
const [action] = await db.insert(issueRecoveryActions).values({ companyId: f.companyId, sourceIssueId: f.issueId,
kind: "configuration_validation", cause: "configuration_incomplete", fingerprint: randomUUID(), nextAction: "Repair configuration" }).returning();
const put = storage.putFile.bind(storage);
storage.putFile = async input => { const result = await put(input);
await db.update(issueRecoveryActions).set({ evidence: { historicalWorkspaceSource: { schema: "paperclip.historical-workspace-source.v1" } } })
.where(eq(issueRecoveryActions.id, action!.id)); return result; };
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true })).rejects.toThrow("workspace_recovery_unresolved");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
});
it("detects overlapping folders reached through symlinked parent directories", async () => {
const f = await seed(), alias = path.join(root, "alias"); await fs.symlink(root, alias);
await db.insert(executionWorkspaces).values({ companyId: f.companyId, name: "Physical alias", mode: "isolated_workspace",
strategyType: "project_primary", providerType: "local_fs", cwd: path.join(alias, "original") });
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("overlapping_workspace_requires_maintenance");
});
it.each(["current", "legacy"])("keeps the parent capture scope when children migrate between retries (%s)", async journal => {
const { parent, child } = await nestedFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
const put = storage.putFile.bind(storage); let fail = true;
storage.putFile = async input => { const saved = await put(input); if (fail) { fail = false; throw new Error("upload reply lost"); } return saved; };
await expect(migrateRemoteWorkspace({ db, storage, ...parent, apply: true, writersStopped: true })).rejects.toThrow("upload reply lost");
if (journal === "legacy") {
const [operation] = await db.select().from(workspaceOperations).where(eq(workspaceOperations.executionWorkspaceId, parent.workspaceId));
const metadata = { ...operation!.metadata }; delete metadata.nestedWorkspaceScope;
await db.update(workspaceOperations).set({ metadata }).where(eq(workspaceOperations.id, operation!.id));
}
await migrateRemoteWorkspace({ db, storage, ...child, apply: true, writersStopped: true });
await migrateRemoteWorkspace({ db, storage, ...parent, apply: true, writersStopped: true });
const authority = readRemoteWorkspaceAuthority((await workspace(parent.workspaceId)).metadata)!;
const reference = await getWorkspaceSnapshot(db, { ...parent, snapshotId: authority.snapshotId! });
const archivePath = path.join(root, "retried-parent.pcws"), targetDir = path.join(root, "retried-parent");
await loadWorkspaceSnapshotArchive({ storage, reference: reference!, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: reference!.manifestDigest });
expect(await fs.readFile(path.join(targetDir, ".paperclip/worktrees/child/child.txt"), "utf8")).toBe("saved child edits\n");
});
async function failedCopybackFixture() {
const f = await seed(), runId = randomUUID();
const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Historical agent", adapterType: "paperclip_runner" }).returning();
const reference = { schema: "paperclip.native-workspace-sync/v3", state: "prepared", workspaceId: f.workspaceId,
leaseId: randomUUID(), providerLeaseId: randomUUID(), remoteCwd: "/workspaces/task",
descriptorSha256: "12".repeat(32), baselineSha256: "34".repeat(32), finalHostSha256: null, resourceDisposition: "stop_and_retain" };
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: agent!.id, nativeIssueId: f.issueId,
runtimeMode: "native", status: "failed", finishedAt: new Date(), runnerProfileJson: { nativeWorkspaceSync: reference } });
await db.insert(nativeRunFinalizations).values({ companyId: f.companyId, issueId: f.issueId, runId, phase: "terminal_failure" });
return { ...f, runId, reference };
}
it("carries unresolved copyback forward during offline migration without declaring recovery complete", async () => {
const f = await failedCopybackFixture();
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("remote_copyback_unresolved");
vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
await migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true });
const row = await workspace(f.workspaceId);
expect(readRemoteWorkspaceAuthority(row.metadata)?.snapshotId).toBeTruthy();
expect(row.metadata?.remoteWorkspaceRecoverySourceRunIds).toEqual([f.runId]);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
expect(run).toMatchObject({ status: "failed", runnerProfileJson: { nativeWorkspaceSync: f.reference } });
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("original saved work\n");
// An old copyback must not recreate or mutate the predecessor's local folder.
await expect(resumeNativeWorkspaceSync({ db, runId: f.runId, target: { kind: "remote", transport: "sandbox", remoteCwd: f.reference.remoteCwd } }))
.rejects.toThrow("native_workspace_sync_source_migrated");
});
it.each(["controller", "finalizer", "live-finalizer", "task", "descriptor"])("keeps unsafe legacy copyback blocked (%s)", async mode => {
const f = await failedCopybackFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
if (mode === "controller") await db.update(heartbeatRuns).set({ controllerLeaseExpiresAt: new Date(Date.now() + 60_000) }).where(eq(heartbeatRuns.id, f.runId));
if (mode === "finalizer") await db.update(nativeRunFinalizations).set({ leaseOwner: randomUUID() }).where(eq(nativeRunFinalizations.runId, f.runId));
if (mode === "live-finalizer") await db.update(nativeRunFinalizations).set({ phase: "observed", leaseOwner: randomUUID(),
leaseExpiresAt: new Date(Date.now() + 60_000) }).where(eq(nativeRunFinalizations.runId, f.runId));
if (mode === "task") {
const [other] = await db.insert(issues).values({ companyId: f.companyId, title: "Different task" }).returning();
await db.update(executionWorkspaces).set({ sourceIssueId: other!.id }).where(eq(executionWorkspaces.id, f.workspaceId));
}
if (mode === "descriptor") await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceSync: { ...f.reference, descriptorSha256: "invalid" } } }).where(eq(heartbeatRuns.id, f.runId));
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true })).rejects.toThrow("remote_copyback_unresolved");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
});
it.each(["retryable_failure", "observed", "no-descriptor"])("preserves an abandoned %s coordinator without replaying its old agent turn", async mode => {
const phase = mode === "no-descriptor" ? "observed" : mode;
const f = await failedCopybackFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
const [oldRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
const checkpoint = { providerSessionId: "original-provider-session", sequence: 17 };
await db.update(heartbeatRuns).set({ runnerProfileJson: { ...(mode === "no-descriptor" ? {} : oldRun!.runnerProfileJson),
sessionCheckpoint: checkpoint } }).where(eq(heartbeatRuns.id, f.runId));
await db.update(issues).set({ assigneeAgentId: oldRun!.agentId, status: "in_progress" })
.where(eq(issues.id, f.issueId));
await db.update(nativeRunFinalizations).set({ phase, leaseOwner: phase === "observed" ? "old-controller" : null,
leaseExpiresAt: phase === "observed" ? new Date(Date.now() - 60_000) : null,
nextAttemptAt: new Date(Date.now() - 30_000) }).where(eq(nativeRunFinalizations.runId, f.runId));
const [before] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
await migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true });
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)?.snapshotId).toBeTruthy();
await db.update(issues).set({ executionRunId: f.runId }).where(eq(issues.id, f.issueId));
expect(await claimNativeSessionResumptions({ db, runnerInstanceId: "replacement", runIds: [f.runId] })).toEqual([]);
expect(await claimNativeRestartRecoveries({ db, restartKind: "hard", runIds: [f.runId] })).toEqual([]);
const [after] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
if (before!.leaseOwner) {
expect(after).toMatchObject({ ...before, leaseOwner: null, leaseExpiresAt: null, updatedAt: expect.any(Date),
recoveryHistory: [{ event: "legacy_workspace_migration_owner_released", workspaceId: f.workspaceId,
operationId: expect.any(String), previousOwner: { phase, leaseOwner: before!.leaseOwner,
leaseExpiresAt: before!.leaseExpiresAt!.toISOString() } }] });
} else expect(after).toEqual(before);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0]).toMatchObject({
status: "failed", runnerProfileJson: { ...(mode === "no-descriptor" ? {} : { nativeWorkspaceSync: f.reference }), sessionCheckpoint: checkpoint,
legacyWorkspaceRecoveryMigration: { workspaceId: f.workspaceId } },
});
});
it.each(["prepared", "finalized", "missing-receipt", "failed-receipt"])("keeps accepted-result finalization independent of migrated file storage (%s)", async mode => {
const state = mode === "prepared" ? "prepared" : "finalized";
const f = await failedCopybackFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
const contractId = randomUUID(), resultId = randomUUID();
await db.insert(completionContracts).values({ id: contractId, companyId: f.companyId, issueId: f.issueId, revision: 1,
schemaVersion: "v1", policyVersion: "v1", risk: "low", completionAuthority: "agent", incompleteCriteriaPolicy: "block",
contractJson: {}, canonicalSha256: "12".repeat(32), createdByActorType: "system", createdByActorId: "test" });
await db.update(heartbeatRuns).set({ status: "succeeded", completionContractId: contractId,
runnerProfileJson: { nativeWorkspaceSync: { ...f.reference, state, finalHostSha256: state === "finalized" ? "56".repeat(32) : null } } })
.where(eq(heartbeatRuns.id, f.runId));
await db.insert(nativeRunResults).values({ id: resultId, companyId: f.companyId, issueId: f.issueId, runId: f.runId,
completionContractId: contractId, serverFingerprint: "saved-result", schemaStatus: "accepted", resultJson: { saved: true }, canonicalSha256: "34".repeat(32) });
await db.update(nativeRunFinalizations).set({ phase: "retryable_failure", resultId }).where(eq(nativeRunFinalizations.runId, f.runId));
const operationId = randomUUID();
if (mode === "finalized" || mode === "failed-receipt") await db.insert(workspaceOperations).values({ id: operationId,
companyId: f.companyId, executionWorkspaceId: f.workspaceId, heartbeatRunId: f.runId, issueId: f.issueId,
phase: "workspace_finalize", status: mode === "finalized" ? "succeeded" : "failed" });
const [before] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
const migrate = () => migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true });
if (mode !== "finalized") await expect(migrate()).rejects.toThrow(state === "prepared"
? "remote_copyback_unresolved" : "execution_finalization_unresolved");
else {
await migrate();
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)?.snapshotId).toBeTruthy();
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0]!.runnerProfileJson)
.not.toHaveProperty("legacyWorkspaceRecoveryMigration");
expect(await attemptNativeWorkspaceFinalization({ db, runId: f.runId }))
.toMatchObject({ kind: "completed", operation: { id: operationId } });
}
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0]).toEqual(before);
});
it("migrates historical records without borrowing another workspace's finalizer", async () => {
const f = await failedCopybackFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
const otherId = randomUUID(), cwd = path.join(root, "longer-historical-folder"); await fs.mkdir(cwd);
await fs.writeFile(path.join(cwd, "history.txt"), "prior task files\n");
await db.insert(executionWorkspaces).values({ id: otherId, companyId: f.companyId, sourceIssueId: f.issueId,
name: "Historical workspace", mode: "isolated_workspace", strategyType: "project_primary", providerType: "local_fs", cwd });
await db.update(nativeRunFinalizations).set({ phase: "observed" }).where(eq(nativeRunFinalizations.runId, f.runId));
expect(await migrateWorkspaceTrees(db, { storage, signal: new AbortController().signal, phase: () => {} })).toBe(2);
for (const id of [f.workspaceId, otherId]) expect(readRemoteWorkspaceAuthority((await workspace(id)).metadata)?.snapshotId).toBeTruthy();
});
it("rejects a legacy descriptor changed while its local files are uploading", async () => {
const f = await failedCopybackFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
const put = storage.putFile.bind(storage);
storage.putFile = async input => {
const result = await put(input);
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceSync: { ...f.reference, providerLeaseId: randomUUID() } } }).where(eq(heartbeatRuns.id, f.runId));
return result;
};
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true })).rejects.toThrow("remote_workspace_migration_source_changed");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
});
it("rejects a descriptor-free allocation changed during upload", async () => {
const f = await failedCopybackFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
await db.insert(environmentLeases).values({ id: f.reference.leaseId, companyId: f.companyId,
issueId: f.issueId, heartbeatRunId: f.runId, provider: "daytona", providerLeaseId: f.reference.providerLeaseId,
status: "released", metadata: { remoteCwd: f.reference.remoteCwd } });
await db.update(heartbeatRuns).set({ runnerProfileJson: {}, resultJson: {
workspaceRestoreRecovery: { schema: "paperclip.workspace-restore-recovery.v1", leaseIds: [f.reference.leaseId] },
} }).where(eq(heartbeatRuns.id, f.runId));
await db.update(nativeRunFinalizations).set({ phase: "observed" }).where(eq(nativeRunFinalizations.runId, f.runId));
const put = storage.putFile.bind(storage);
storage.putFile = async input => {
const result = await put(input);
await db.update(environmentLeases).set({ providerLeaseId: randomUUID() }).where(eq(environmentLeases.id, f.reference.leaseId));
return result;
};
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true }))
.rejects.toThrow("remote_workspace_migration_source_changed");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
});
it("does not release a coordinator whose expired owner changed during upload", async () => {
const f = await failedCopybackFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
await db.update(nativeRunFinalizations).set({ phase: "observed", leaseOwner: "journaled-owner",
leaseExpiresAt: new Date(Date.now() - 60_000) }).where(eq(nativeRunFinalizations.runId, f.runId));
const put = storage.putFile.bind(storage);
storage.putFile = async input => {
const result = await put(input);
await db.update(nativeRunFinalizations).set({ leaseOwner: "different-owner" }).where(eq(nativeRunFinalizations.runId, f.runId));
return result;
};
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true }))
.rejects.toThrow("remote_workspace_migration_source_changed");
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0])
.toMatchObject({ phase: "observed", leaseOwner: "different-owner", recoveryHistory: [] });
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
});
it("preserves a child task's recovery reference to its ancestor's workspace", async () => {
const f = await failedCopybackFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
const [parent] = await db.insert(issues).values({ companyId: f.companyId, title: "Parent task" }).returning();
await db.update(issues).set({ parentId: parent!.id }).where(eq(issues.id, f.issueId));
await db.update(executionWorkspaces).set({ sourceIssueId: parent!.id }).where(eq(executionWorkspaces.id, f.workspaceId));
await migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true });
expect((await workspace(f.workspaceId)).sourceIssueId).toBe(parent!.id);
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]!.executionWorkspaceId).toBe(f.workspaceId);
await expect(resumeNativeWorkspaceSync({ db, runId: f.runId, target: { kind: "remote", transport: "sandbox", remoteCwd: f.reference.remoteCwd } }))
.rejects.toThrow("native_workspace_sync_source_migrated");
});
it("fails closed if the durable legacy migration receipt changes", async () => {
const f = await failedCopybackFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
await migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: true });
const row = await workspace(f.workspaceId);
await db.update(executionWorkspaces).set({ metadata: { ...row.metadata, legacyWorkspaceRecoveryMigration: { operationId: randomUUID() } } }).where(eq(executionWorkspaces.id, f.workspaceId));
await expect(resumeNativeWorkspaceSync({ db, runId: f.runId, target: { kind: "remote", transport: "sandbox", remoteCwd: f.reference.remoteCwd } }))
.rejects.toThrow("legacy_workspace_migration_receipt_invalid");
});
it("blocks distinct legacy records that alias the same folder", async () => {
const f = await seed(), id = randomUUID();
await db.insert(executionWorkspaces).values({ id, companyId: f.companyId, name: "Historical alias", mode: "isolated_workspace", strategyType: "project_primary", providerType: "local_fs", cwd: f.cwd });
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("overlapping_workspace_requires_maintenance");
});
it.each(["cleanup_failed", "adapter_managed"])("blocks enrollment instead of abandoning unsupported local files (%s)", async kind => {
const f = await seed(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
await db.update(executionWorkspaces).set(kind === "cleanup_failed" ? { status: kind } : { providerType: kind })
.where(eq(executionWorkspaces.id, f.workspaceId));
await expect(migrateWorkspaceTrees(db, { storage, signal: new AbortController().signal, phase: () => {} }))
.rejects.toThrow(kind === "cleanup_failed" ? "workspace_not_active" : "workspace_provider_unsupported");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("original saved work\n");
});
it.each(["changed", "corrupt", "late-change", "abort"])("keeps the parent local when nested coverage is unsafe (%s)", async mode => {
const { parent, child } = await nestedFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
await migrateRemoteWorkspace({ db, storage, ...child, apply: true, writersStopped: true });
const authority = readRemoteWorkspaceAuthority((await workspace(child.workspaceId)).metadata)!;
const reference = await getWorkspaceSnapshot(db, { ...child, snapshotId: authority.snapshotId! });
const controller = new AbortController();
if (mode === "changed") await fs.writeFile(path.join(child.cwd, "child.txt"), "changed child bytes");
if (mode === "corrupt") await storage.deleteObject(child.companyId, reference!.objectKey);
if (mode === "late-change" || mode === "abort") {
const put = storage.putFile.bind(storage); let once = true;
storage.putFile = async input => { const result = await put(input); if (once) { once = false;
if (mode === "abort") controller.abort(); else await fs.writeFile(path.join(child.cwd, "child.txt"), "changed during upload");
} return result; };
}
await expect(migrateRemoteWorkspace({ db, storage, ...parent, apply: true, writersStopped: true, signal: controller.signal })).rejects.toThrow();
expect(readRemoteWorkspaceAuthority((await workspace(parent.workspaceId)).metadata)).toBeNull();
expect(await fs.readFile(path.join(parent.cwd, "saved.txt"), "utf8")).toBe("original saved work\n");
if (mode === "abort") {
await migrateRemoteWorkspace({ db, storage, ...parent, apply: true, writersStopped: true });
expect(readRemoteWorkspaceAuthority((await workspace(parent.workspaceId)).metadata)?.snapshotId).toBeTruthy();
}
});
it("resumes an interrupted maintenance batch in leaf-first order without duplicating imports", async () => {
const { parent, child } = await nestedFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
const seen: string[] = [], controller = new AbortController();
await expect(migrateWorkspaceTrees(db, { storage, signal: controller.signal, phase: name => {
seen.push(name); if (name === `workspace:${parent.workspaceId}`) controller.abort();
} })).rejects.toThrow();
expect(seen).toEqual([`workspace:${child.workspaceId}`, `workspace:${parent.workspaceId}`]);
const childAuthority = readRemoteWorkspaceAuthority((await workspace(child.workspaceId)).metadata);
expect(await migrateWorkspaceTrees(db, { storage, signal: new AbortController().signal, phase: () => {} })).toBe(1);
expect(await migrateWorkspaceTrees(db, { storage, signal: new AbortController().signal, phase: () => {} })).toBe(0);
expect(readRemoteWorkspaceAuthority((await workspace(child.workspaceId)).metadata)).toEqual(childAuthority);
});
it("checks transitive nested coverage before omitting a child tree", async () => {
const { parent, child } = await nestedFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
const id = randomUUID(), cwd = path.join(child.cwd, ".paperclip", "worktrees", "grandchild");
await fs.mkdir(cwd, { recursive: true }); await fs.writeFile(path.join(cwd, "deep.txt"), "deep work");
const [issue] = await db.insert(issues).values({ companyId: parent.companyId, title: "Grandchild task" }).returning();
await db.insert(executionWorkspaces).values({ id, companyId: parent.companyId, sourceIssueId: issue!.id,
name: "Grandchild", mode: "isolated_workspace", strategyType: "project_primary", providerType: "local_fs", cwd });
await migrateRemoteWorkspace({ db, storage, companyId: parent.companyId, workspaceId: id, apply: true, writersStopped: true });
await migrateRemoteWorkspace({ db, storage, ...child, apply: true, writersStopped: true });
await fs.writeFile(path.join(cwd, "deep.txt"), "new deep work");
await expect(migrateRemoteWorkspace({ db, storage, ...parent, apply: true, writersStopped: true })).rejects.toThrow("nested_workspace_source_changed");
expect(readRemoteWorkspaceAuthority((await workspace(parent.workspaceId)).metadata)).toBeNull();
});
async function gitArchiveFixture(repoUrl = "https://example.invalid/repository.git", home = root) {
const f = await seed(), projectId = randomUUID(), projectWorkspaceId = randomUUID(), branchName = "saved-task";
await fs.mkdir(home, { recursive: true });
vi.stubEnv("PAPERCLIP_HOME", await fs.realpath(home)); vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default");
const base = resolveManagedProjectWorkspaceDir({ companyId: f.companyId, projectId, repoName: "repository" });
await fs.mkdir(base, { recursive: true }); await git(base, "init", "-q", "-b", "main");
await fs.writeFile(path.join(base, "base.txt"), "base\n"); await git(base, "add", "."); await git(base, "commit", "-qm", "base");
await git(base, "remote", "add", "origin", repoUrl); const upstream = await git(base, "rev-parse", "HEAD");
const cwd = path.join(base, ".paperclip", "worktrees", branchName);
await git(base, "worktree", "add", "-b", branchName, cwd);
await fs.copyFile(path.join(f.cwd, "saved.txt"), path.join(cwd, "saved.txt")); await fs.rm(f.cwd, { recursive: true });
await git(cwd, "add", "."); await git(cwd, "commit", "-qm", "saved task commit"); const commit = await git(cwd, "rev-parse", "HEAD");
await db.insert(projects).values({ id: projectId, companyId: f.companyId, name: "Archived project" });
await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, companyId: f.companyId, projectId, name: "Source", cwd: base, repoUrl });
await db.update(executionWorkspaces).set({ cwd, providerRef: cwd, projectId, projectWorkspaceId, repoUrl, baseRef: "main",
branchName, strategyType: "git_worktree", providerType: "git_worktree", metadata: { createdByRuntime: true, gitBranchOwnershipVersion: 1 } }).where(eq(executionWorkspaces.id, f.workspaceId));
return { ...f, cwd, base, upstream, commit, projectId, projectWorkspaceId, repoUrl, branchName };
}
it.each(["legacy", "verified", "retry"])("recovers an archived Git workspace from its exact branch-deletion receipt (%s)", async receiptKind => {
const f = await gitArchiveFixture(), closedAt = new Date();
await db.update(executionWorkspaces).set({ status: "archived", closedAt }).where(eq(executionWorkspaces.id, f.workspaceId));
if (receiptKind !== "verified") {
await git(f.base, "merge", "--ff-only", f.branchName);
await git(f.base, "worktree", "remove", f.cwd);
const receipt = await git(f.base, "branch", "-d", f.branchName);
await db.insert(workspaceOperations).values({ companyId: f.companyId, executionWorkspaceId: f.workspaceId,
phase: "worktree_cleanup", status: "succeeded", command: `git branch -d ${f.branchName}`,
stdoutExcerpt: receipt, startedAt: new Date(), finishedAt: new Date() });
} else {
const cleaned = await cleanupExecutionWorkspaceArtifacts({ workspace: await workspace(f.workspaceId),
projectWorkspace: { cwd: f.base, cleanupCommand: null }, expectedBranchHeadSha: f.commit, runCleanupCommands: false,
recorder: workspaceOperationService(db).createRecorder({ companyId: f.companyId, executionWorkspaceId: f.workspaceId, issueId: f.issueId }) });
expect(cleaned.warnings).toEqual([]);
}
await fs.writeFile(path.join(f.base, "base.txt"), "newer default branch\n");
await git(f.base, "add", "base.txt"); await git(f.base, "commit", "-qm", "new default branch work");
if (receiptKind === "retry") {
const put = storage.putFile.bind(storage); let fail = true;
storage.putFile = async input => { const result = await put(input); if (fail) { fail = false; throw new Error("upload reply lost"); } return result; };
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("upload reply lost");
}
await migrateRemoteWorkspace({ db, storage, ...f, 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, "legacy-git.pcws"), targetDir = path.join(root, "restored-legacy-git");
await loadWorkspaceSnapshotArchive({ storage, reference: ref!, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref!.manifestDigest });
expect(await git(targetDir, "rev-parse", "HEAD")).toBe(f.commit);
expect(await fs.readFile(path.join(targetDir, "base.txt"), "utf8")).toBe("base\n");
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("original saved work\n");
});
it.each(["valid", "foreign-cache", "wrong-origin", "missing-receipt", "revoked-receipt", "private-cache", "journaled-shared", "journaled-source-changed"])("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);
if (!["private-cache", "journaled-shared", "journaled-source-changed"].includes(state)) 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" || state === "private-cache") 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.startsWith("journaled-")) {
const put = storage.putFile.bind(storage); let fail = true;
storage.putFile = async input => { const saved = await put(input); if (fail) { fail = false; throw new Error("upload reply lost"); } return saved; };
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("upload reply lost");
const [operation] = await db.select().from(workspaceOperations).where(and(eq(workspaceOperations.executionWorkspaceId, f.workspaceId), eq(workspaceOperations.phase, "remote_workspace_migration")));
await db.update(workspaceOperations).set({ metadata: { ...operation!.metadata,
archivedGitSource: { ...(operation!.metadata!.archivedGitSource as object), sourcePath: f.base } } }).where(eq(workspaceOperations.id, operation!.id));
if (state === "journaled-source-changed") await git(f.base, "remote", "set-url", "origin", "https://example.invalid/foreign.git");
}
if (!["valid", "private-cache", "journaled-shared"].includes(state)) {
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 });
await fs.rm(privateBase, { recursive: true, force: 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();
});
it.each(["valid", "changed-branch", "competing-owner", "registered-branch", "unowned", "missing-branch", "active-online", "active-offline", "user-owned-receipt", "user-owned-force-receipt", "user-owned-revoked-receipt", "user-owned-quoted-receipt", "detached-registration", "orphan-index"])("preserves a missing checkout from its retained owned branch (%s)", async mode => {
const f = await gitArchiveFixture(undefined, mode === "user-owned-quoted-receipt" ? path.join(root, "home with spaces") : root), closedAt = new Date();
const active = mode.startsWith("active-");
await db.update(executionWorkspaces).set({ status: active ? "active" : "archived", closedAt: active ? null : closedAt })
.where(eq(executionWorkspaces.id, f.workspaceId));
if (mode === "user-owned-quoted-receipt") {
await db.update(executionWorkspaces).set({ metadata: { createdByRuntime: false, gitBranchOwnershipVersion: 1 } }).where(eq(executionWorkspaces.id, f.workspaceId));
const cleaned = await cleanupExecutionWorkspaceArtifacts({ workspace: await workspace(f.workspaceId),
projectWorkspace: { cwd: f.base, cleanupCommand: null }, runCleanupCommands: false, forceWorktreeRemoval: false,
recorder: workspaceOperationService(db).createRecorder({ companyId: f.companyId, executionWorkspaceId: f.workspaceId, issueId: f.issueId }) });
expect(cleaned.warnings).toEqual([]);
} else if (mode === "detached-registration") {
await git(f.cwd, "checkout", "--detach");
await fs.writeFile(path.join(f.cwd, "newer.txt"), "recoverable detached work");
await git(f.cwd, "add", "."); await git(f.cwd, "commit", "-qm", "newer detached work");
await fs.rm(f.cwd, { recursive: true });
} else await git(f.base, "worktree", "remove", f.cwd);
await fs.writeFile(path.join(f.base, "base.txt"), "new shared default branch\n");
await git(f.base, "add", "."); await git(f.base, "commit", "-qm", "unrelated shared work");
if (mode === "orphan-index") {
const admin = path.join(f.base, ".git", "worktrees", "orphan");
await fs.mkdir(admin, { recursive: true }); await fs.copyFile(path.join(f.base, ".git", "index"), path.join(admin, "index"));
await fs.writeFile(path.join(admin, "locked"), "retained staged recovery work\n");
}
if (mode === "changed-branch") {
const put = storage.putFile.bind(storage);
storage.putFile = async input => { const saved = await put(input); await git(f.base, "update-ref", `refs/heads/${f.branchName}`, await git(f.base, "rev-parse", "HEAD")); return saved; };
}
if (mode === "competing-owner") {
const [issue] = await db.insert(issues).values({ companyId: f.companyId, title: "Different branch owner" }).returning();
await db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: issue!.id, projectId: f.projectId,
name: "Other owner", mode: "isolated_workspace", strategyType: "git_worktree", providerType: "git_worktree",
repoUrl: f.repoUrl, branchName: f.branchName, cwd: path.join(root, "other-owner") });
}
if (mode === "registered-branch") await git(f.base, "worktree", "add", path.join(root, "registered"), f.branchName);
if (mode.startsWith("user-owned-") && mode !== "user-owned-quoted-receipt") {
await db.update(executionWorkspaces).set({ metadata: { createdByRuntime: false, gitBranchOwnershipVersion: 1 } }).where(eq(executionWorkspaces.id, f.workspaceId));
const [receipt] = await db.insert(workspaceOperations).values({ companyId: f.companyId, executionWorkspaceId: f.workspaceId,
phase: "worktree_cleanup", status: "succeeded", startedAt: new Date(), finishedAt: new Date(),
command: `git worktree remove ${mode === "user-owned-force-receipt" ? "--force " : ""}${f.cwd}`,
metadata: { cleanupAction: "worktree_remove", workspaceId: f.workspaceId, workspacePath: f.cwd, branchName: f.branchName } }).returning();
if (mode === "user-owned-revoked-receipt") {
const put = storage.putFile.bind(storage);
storage.putFile = async input => { const saved = await put(input); await db.update(workspaceOperations).set({ status: "failed" })
.where(eq(workspaceOperations.id, receipt!.id)); return saved; };
}
}
if (mode === "unowned") await db.update(executionWorkspaces).set({ metadata: { gitBranchOwnershipVersion: 1 } }).where(eq(executionWorkspaces.id, f.workspaceId));
if (mode === "missing-branch") await git(f.base, "branch", "-D", f.branchName);
const offline = mode === "active-offline";
if (offline) vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
const migrate = () => migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: offline });
if (!["valid", "active-offline", "user-owned-receipt", "user-owned-quoted-receipt"].includes(mode)) {
await expect(migrate()).rejects.toThrow();
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull(); return;
}
await migrate();
const row = await workspace(f.workspaceId), authority = readRemoteWorkspaceAuthority(row.metadata)!;
expect(row.status).toBe(active ? "active" : "archived");
expect(authority.source).toMatchObject({ kind: "git", ref: f.commit });
const [operation] = await db.select().from(workspaceOperations).where(and(
eq(workspaceOperations.executionWorkspaceId, f.workspaceId), eq(workspaceOperations.phase, "remote_workspace_migration")));
expect(operation!.metadata?.archivedGitSource).toMatchObject({ commit: f.commit, retainedBranch: f.branchName });
await fs.rm(f.base, { recursive: true });
const ref = await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! });
const archivePath = path.join(root, "retained-branch.pcws"), targetDir = path.join(root, "restored-retained-branch");
await loadWorkspaceSnapshotArchive({ storage, reference: ref!, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref!.manifestDigest });
expect(await git(targetDir, "rev-parse", "HEAD")).toBe(f.commit);
expect(await fs.readFile(path.join(targetDir, "base.txt"), "utf8")).toBe("base\n");
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("original saved work\n");
});
async function serveRepository() {
const directory = path.join(root, "http"), bare = path.join(directory, "repository.git");
await fs.mkdir(bare, { recursive: true }); await git(bare, "init", "--bare", "--initial-branch=main");
const server = createServer(async (request, response) => {
const relative = new URL(request.url!, "http://localhost").pathname.slice(1);
const target = path.resolve(directory, relative);
if (!target.startsWith(`${directory}/`)) { response.writeHead(403).end(); return; }
try { response.end(await fs.readFile(target)); } catch { response.writeHead(404).end(); }
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve)); servers.push(server);
const address = server.address(); if (!address || typeof address === "string") throw new Error("test server not bound");
return { bare, repoUrl: `http://127.0.0.1:${address.port}/repository.git` };
}
it.each(["present", "root-history", "pruned", "retry", "missing-base-failed-upload-retry", "source-excludes", "source-excludes-symlink", "missing-base", "missing-base-retry", "missing-base-uploaded-retry", "missing-base-changed-retry", "nested", "nested-linked", "nested-retry", "nested-source-changed-retry", "unapproved-nested", "missing", "expired", "corrupt"])("preserves archived Git edits or refuses an unavailable restore point (%s)", async state => {
const remote = state.startsWith("missing-base") ? await serveRepository() : null;
const f = await gitArchiveFixture(remote?.repoUrl);
if (remote) { await git(remote.bare, "fetch", f.base, "main:refs/heads/main"); await git(remote.bare, "update-server-info"); }
let rootHistory: string | undefined;
if (state === "root-history") {
await fs.writeFile(path.join(f.cwd, "history-only.txt"), "saved root history\n");
await git(f.cwd, "add", "history-only.txt"); await git(f.cwd, "commit", "-qm", "root history");
rootHistory = await git(f.cwd, "rev-parse", "HEAD"); await git(f.cwd, "reset", "--hard", f.commit);
}
await fs.writeFile(path.join(f.cwd, "saved.txt"), "staged edit\n"); await git(f.cwd, "add", "saved.txt");
await fs.writeFile(path.join(f.cwd, "saved.txt"), "unstaged edit\n");
if (state.startsWith("source-excludes")) {
await fs.appendFile(path.join(f.base, ".git", "info", "exclude"), "\n/.paperclip/recovered-runs/\n");
const recovered = path.join(f.cwd, ".paperclip", "recovered-runs", "saved");
await fs.mkdir(recovered, { recursive: true });
await fs.writeFile(path.join(recovered, "repair-receipt.json"), '{"preserved":true}\n');
await fs.writeFile(path.join(recovered, "source-delta.bundle"), Buffer.from([0, 255, 17, 128]));
}
const sourceExcludes = state.startsWith("source-excludes") ? await fs.readFile(path.join(f.base, ".git", "info", "exclude")) : null;
const captured = (await captureRestorePoint({ cwd: f.cwd, upstreamCommits: [f.upstream] }))!;
if (rootHistory) expect(captured.manifest.preserved).toContain(rootHistory);
const id = randomUUID(), generation = 1, sealed = await sealRestorePoint(captured.payload,
{ companyId: f.companyId, workspaceId: f.workspaceId, restorePointId: id, generation });
const objectKey = `${f.companyId}/git-restore-points/${f.workspaceId}/${generation}/${id}.pcrp`;
await storage.putFile({ companyId: f.companyId, namespace: "git-restore-points", objectKey,
originalFilename: "git.pcrp", contentType: "application/octet-stream", body: sealed.ciphertext });
const restorePoint = { schema: "paperclip.git-restore-point.v1", id, generation, objectKey, byteSize: sealed.ciphertext.length,
sha256: sealed.sha256, key: sealed.key, head: captured.manifest.head, index: captured.manifest.index,
worktree: captured.manifest.worktree, ignored: captured.manifest.ignored, modes: captured.manifest.modes, headOnRemote: false };
let contents;
if (state.startsWith("nested") || state === "unapproved-nested") {
const sourceCwd = path.join(root, "nested-source");
await fs.mkdir(sourceCwd); await git(sourceCwd, "init", "-q", "-b", "main");
await fs.writeFile(path.join(sourceCwd, "nested.txt"), "nested source work\n");
await git(sourceCwd, "add", "."); await git(sourceCwd, "commit", "-qm", "nested source");
const source = { id: randomUUID(), repoUrl: pathToFileURL(sourceCwd).href, repoRef: "main", cwd: sourceCwd };
const name = managedProjectCheckoutName(source), nestedCwd = path.join(f.cwd, ".paperclip-repositories", name);
let nestedPoint;
if (state === "nested-linked") {
const [base] = nestedRepositoryBaseCandidates({ companyId: f.companyId, projectId: f.projectId, repoUrl: source.repoUrl });
await fs.mkdir(path.dirname(base!), { recursive: true }); await git(root, "clone", source.repoUrl, base!);
await fs.mkdir(path.dirname(nestedCwd), { recursive: true });
await addNestedRepositoryWorktree({ base: base!, cwd: nestedCwd, repoUrl: source.repoUrl, repoRef: "main" });
await fs.writeFile(path.join(nestedCwd, "nested.txt"), "nested staged edit\n"); await git(nestedCwd, "add", "nested.txt");
await fs.writeFile(path.join(nestedCwd, "nested.txt"), "nested unstaged edit\n");
const captured = (await captureRestorePoint({ cwd: nestedCwd, upstreamCommits: [await git(sourceCwd, "rev-parse", "HEAD")] }))!;
const id = randomUUID(), generation = 1, sealed = await sealRestorePoint(captured.payload,
{ companyId: f.companyId, workspaceId: f.workspaceId, restorePointId: id, generation });
const objectKey = `${f.companyId}/git-restore-points/${f.workspaceId}/${generation}/checkouts/${name}/${id}.pcrp`;
await storage.putFile({ companyId: f.companyId, namespace: "git-restore-points", objectKey,
originalFilename: null, contentType: "application/octet-stream", body: sealed.ciphertext });
nestedPoint = { schema: "paperclip.git-restore-point.v1" as const, id, generation, objectKey, byteSize: sealed.ciphertext.length,
sha256: sealed.sha256, key: sealed.key, head: captured.manifest.head, index: captured.manifest.index,
worktree: captured.manifest.worktree, ignored: captured.manifest.ignored, modes: captured.manifest.modes, headOnRemote: true };
} else { await git(f.cwd, "clone", source.repoUrl, nestedCwd); await git(nestedCwd, "checkout", "--detach"); }
await fs.mkdir(path.join(nestedCwd, ".claude"));
await fs.writeFile(path.join(nestedCwd, ".claude", "settings.local.json"), '{"permissions":{"allow":["Read"]}}');
contents = await captureGitWorkspaceContents(f.cwd, { companyId: f.companyId, workspaceId: f.workspaceId },
[{ ...await readManagedCheckoutContents(nestedCwd, name, source), ...(nestedPoint ? { restorePoint: nestedPoint } : {}) }]);
if (state !== "unapproved-nested") await db.insert(projectWorkspaces).values({ ...source, companyId: f.companyId, projectId: f.projectId, name: "Nested source" });
}
await db.update(executionWorkspaces).set({ status: "archived", closedAt: new Date(), metadata: {
createdByRuntime: true, gitBranchOwnershipVersion: 1, gitCacheRestoration: { schema: "paperclip.git-cache-restoration.v2", companyId: f.companyId,
workspaceId: f.workspaceId, sourceIssueId: f.issueId, repoUrl: f.repoUrl, commit: f.commit, branchName: f.branchName,
sourceKind: "managed_project", projectId: f.projectId, sourcePath: f.base, worktreePath: f.cwd,
gitBranchOwnershipVersion: 1, verifiedAt: new Date().toISOString(), restorePoint, contents },
} }).where(eq(executionWorkspaces.id, f.workspaceId));
await git(f.base, "worktree", "remove", "--force", f.cwd); await git(f.base, "branch", "-D", f.branchName);
if (state === "source-excludes-symlink") {
const outside = path.join(root, "private-ignore.txt"); await fs.writeFile(outside, "/outside-private-pattern/\n");
await fs.rm(path.join(f.base, ".git", "info", "exclude"));
await fs.symlink(outside, path.join(f.base, ".git", "info", "exclude"));
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("remote_workspace_migration_archived_git_source_unavailable");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await fs.readFile(outside, "utf8")).toBe("/outside-private-pattern/\n");
return;
}
if (state.startsWith("missing-base")) await fs.rm(f.base, { recursive: true });
if (state === "unapproved-nested") {
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow();
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect((await workspace(f.workspaceId)).status).toBe("archived");
return;
}
if (state === "pruned") {
await git(f.base, "reflog", "expire", "--expire=now", "--all");
await git(f.base, "gc", "--prune=now");
await expect(git(f.base, "cat-file", "-e", `${f.commit}^{commit}`)).rejects.toThrow();
}
if (["missing", "expired", "corrupt"].includes(state)) {
if (state === "missing") await storage.deleteObject(f.companyId, objectKey);
else {
const row = await workspace(f.workspaceId);
const recipe = row.metadata!.gitCacheRestoration as Record<string, unknown>;
await db.update(executionWorkspaces).set({ metadata: { ...row.metadata, gitCacheRestoration: { ...recipe,
restorePoint: { ...restorePoint, ...(state === "expired" ? { expiredAt: new Date().toISOString() } : { sha256: "00".repeat(32) }) },
} } }).where(eq(executionWorkspaces.id, f.workspaceId));
}
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow();
const row = await workspace(f.workspaceId);
expect(row).toMatchObject({ status: "archived", providerType: "git_worktree", cwd: f.cwd });
expect(readRemoteWorkspaceAuthority(row.metadata)).toBeNull();
expect(await fs.stat(f.cwd).catch(() => null)).toBeNull();
return;
}
if (["retry", "missing-base-retry", "missing-base-changed-retry", "nested-retry", "nested-source-changed-retry"].includes(state)) {
const put = storage.putFile.bind(storage); let fail = true;
storage.putFile = async input => { const result = await put(input); if (fail) { fail = false; throw new Error("upload reply lost"); } return result; };
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("upload reply lost");
}
let failedAttempt: Record<string, unknown> | undefined;
if (state === "missing-base-failed-upload-retry") {
const put = storage.putFile.bind(storage); let fail = true;
storage.putFile = async input => {
if (fail && input.namespace === "workspace-snapshots") { fail = false; throw new Error("upload_part InternalError"); }
return put(input);
};
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("upload_part InternalError");
const [pending] = await db.select().from(executionWorkspaceSnapshots);
expect(pending).toMatchObject({ status: "pending", objectKey: null });
failedAttempt = (pending!.metadata!.uploads as Record<string, unknown>[])[0]!;
expect((await storage.headObject(f.companyId, failedAttempt.objectKey as string)).exists).toBe(false);
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
}
if (state === "missing-base-uploaded-retry") {
const get = storage.getObject.bind(storage); let reads = 0;
storage.getObject = async (...args) => {
if (args[1].includes("/workspace-snapshots/") && ++reads === 2) throw new Error("restore reply lost");
return get(...args);
};
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("restore reply lost");
expect((await db.select().from(executionWorkspaceSnapshots))[0]?.status).toBe("uploaded");
}
const interrupted = state.startsWith("missing-base-") || state.startsWith("nested-")
? (await db.select().from(workspaceOperations).where(eq(workspaceOperations.phase, "remote_workspace_migration")))[0] : null;
if (interrupted && remote) {
const tree = await git(remote!.bare, "rev-parse", "main^{tree}");
const next = await git(remote!.bare, "commit-tree", tree, "-p", f.upstream, "-m", "unrelated remote release");
await git(remote!.bare, "update-ref", "refs/heads/main", next);
await git(remote!.bare, "update-ref", "refs/heads/new-unrelated-branch", next);
await git(remote!.bare, "update-server-info");
}
if (state === "nested-source-changed-retry") {
await db.update(projectWorkspaces).set({ repoUrl: "https://example.invalid/replaced-repository.git" })
.where(eq(projectWorkspaces.id, contents!.checkouts[0]!.source.id));
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true }))
.rejects.toThrow("remote_workspace_migration_archived_git_source_unavailable");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect((await workspace(f.workspaceId)).status).toBe("archived");
return;
}
if (state === "missing-base-changed-retry") {
const get = storage.getObject.bind(storage); let expire = true;
storage.getObject = async (...args) => {
const result = await get(...args);
if (expire && args[1].includes("/workspace-snapshots/")) {
expire = false;
const row = await workspace(f.workspaceId);
const recipe = row.metadata!.gitCacheRestoration as Record<string, unknown>;
await db.update(executionWorkspaces).set({ metadata: { ...row.metadata, gitCacheRestoration: {
...recipe, restorePoint: { ...restorePoint, expiredAt: new Date().toISOString() },
} } }).where(eq(executionWorkspaces.id, f.workspaceId));
}
return result;
};
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("remote_workspace_migration_publication_changed");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect((await workspace(f.workspaceId)).status).toBe("archived");
expect((await db.select().from(executionWorkspaceSnapshots))[0]?.status).toBe("uploaded");
return;
}
await migrateRemoteWorkspace({ db, storage, ...f, apply: true });
if (failedAttempt) {
const [saved] = await db.select().from(executionWorkspaceSnapshots);
const attempts = saved!.metadata!.uploads as Record<string, unknown>[];
expect(saved!.status).toBe("committed");
expect(attempts).toHaveLength(2);
expect(attempts[0]).toEqual(failedAttempt);
expect(attempts[1]!.manifestDigest).not.toBe(failedAttempt.manifestDigest);
expect(await db.select().from(workspaceOperations).where(eq(workspaceOperations.phase, "remote_workspace_migration")))
.toEqual([expect.objectContaining({ id: saved!.sourceOperationId, status: "succeeded" })]);
}
if (interrupted) {
const operations = await db.select().from(workspaceOperations).where(eq(workspaceOperations.phase, "remote_workspace_migration"));
expect(operations).toHaveLength(1);
expect(operations[0]).toMatchObject({ id: interrupted.id, status: "succeeded" });
}
const authority = readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)!;
if (sourceExcludes) expect(await fs.readFile(path.join(f.base, ".git", "info", "exclude"))).toEqual(sourceExcludes);
await fs.rm(f.base, { recursive: true, force: true }); await fs.rm(path.join(root, "nested-source"), { recursive: true, force: true }); await storage.deleteObject(f.companyId, objectKey);
const ref = await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! });
const archivePath = path.join(root, "retained-git.pcws"), targetDir = path.join(root, "restored-retained-git");
await loadWorkspaceSnapshotArchive({ storage, reference: ref!, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref!.manifestDigest });
expect(await git(targetDir, "rev-parse", "HEAD")).toBe(f.commit);
if (rootHistory) {
await git(targetDir, "reflog", "expire", "--expire=now", "--all"); await git(targetDir, "gc", "--prune=now");
expect(await git(targetDir, "show", `${rootHistory}:history-only.txt`)).toBe("saved root history");
}
if (state === "nested-linked") {
await fs.rm(contents!.checkouts[0]!.worktree!.base, { recursive: true });
const nested = path.join(targetDir, ".paperclip-repositories", contents!.checkouts[0]!.name);
expect((await fs.lstat(path.join(nested, ".git"))).isDirectory()).toBe(true);
expect(await fs.readFile(path.join(nested, "nested.txt"), "utf8")).toBe("nested unstaged edit\n");
expect(await git(nested, "show", ":nested.txt")).toBe("nested staged edit");
}
if (state.startsWith("nested")) {
if (state !== "nested-linked") expect(await fs.readFile(path.join(targetDir, ".paperclip-repositories", contents!.checkouts[0]!.name, "nested.txt"), "utf8")).toBe("nested source work\n");
expect(JSON.parse(await fs.readFile(path.join(targetDir, ".paperclip-repositories", contents!.checkouts[0]!.name, ".claude/settings.local.json"), "utf8")))
.toEqual({ permissions: { allow: ["Read"] } });
}
expect(await git(targetDir, "show", ":saved.txt")).toBe("staged edit");
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("unstaged edit\n");
if (state === "source-excludes") {
const recovered = path.join(targetDir, ".paperclip", "recovered-runs", "saved");
expect(await fs.readFile(path.join(recovered, "repair-receipt.json"), "utf8")).toBe('{"preserved":true}\n');
expect(await fs.readFile(path.join(recovered, "source-delta.bundle"))).toEqual(Buffer.from([0, 255, 17, 128]));
expect(await git(targetDir, "check-ignore", ".paperclip/recovered-runs/saved/repair-receipt.json"))
.toBe(".paperclip/recovered-runs/saved/repair-receipt.json");
}
});
async function retainDirectory(f: Awaited<ReturnType<typeof seed>>) {
const captured = await captureTaskDirectorySnapshot(f.cwd), id = randomUUID(), generation = 1;
const sealed = await sealRestorePoint(captured.payload, { companyId: f.companyId, workspaceId: f.workspaceId, restorePointId: id, generation });
const objectKey = `${f.companyId}/git-restore-points/${f.workspaceId}/${generation}/task-directory/${id}.pcrp`;
await storage.putFile({ companyId: f.companyId, namespace: "git-restore-points", objectKey,
originalFilename: "retained.pcrp", contentType: "application/octet-stream", body: sealed.ciphertext });
const ref: TaskDirectoryRestorePointRef = { schema: "paperclip.task-directory-restore-point.v1", id, generation,
objectKey, sha256: sealed.sha256, byteSize: sealed.ciphertext.length, key: sealed.key,
digest: captured.digest, files: captured.files, excluded: captured.excluded };
await db.update(executionWorkspaces).set({ status: "archived", closedAt: new Date(), metadata: {
isolatedTaskDirectoryRetention: { schema: "paperclip.task-directory-retention.v1", generation, removed: true,
trash: path.join(root, "removed-trash"), restorePoint: ref },
} }).where(eq(executionWorkspaces.id, f.workspaceId));
await fs.rm(f.cwd, { recursive: true });
return ref;
}
it.each(["historical", "canonical"] as const)("imports an archived directory from its encrypted restore point after the old folder is gone (%s)", async location => {
let f = await seed();
if (location === "canonical") {
vi.stubEnv("PAPERCLIP_HOME", await fs.realpath(root));
const cwd = path.join(resolvePaperclipInstanceRoot(), "isolated-workspaces", f.companyId, f.issueId);
await fs.mkdir(path.dirname(cwd), { recursive: true });
await fs.rename(f.cwd, cwd);
await db.update(executionWorkspaces).set({ cwd, providerRef: cwd }).where(eq(executionWorkspaces.id, f.workspaceId));
f = { ...f, cwd };
}
const retained = await retainDirectory(f);
expect(await inventoryRemoteWorkspaceMigration(db, f)).toMatchObject({ eligible: true });
await migrateRemoteWorkspace({ db, storage, ...f, apply: true });
const saved = await workspace(f.workspaceId), authority = readRemoteWorkspaceAuthority(saved.metadata)!;
expect(saved.status).toBe("archived");
expect(await fs.stat(f.cwd).catch(() => null)).toBeNull();
await storage.deleteObject(f.companyId, retained.objectKey);
const ref = await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! });
const archivePath = path.join(root, "retained.pcws"), targetDir = path.join(root, "restored-retained");
await loadWorkspaceSnapshotArchive({ storage, reference: ref!, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref!.manifestDigest });
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("original saved work\n");
});
it("migrates an exactly authenticated empty retained payload without rewriting its missing source", async () => {
const f = await seed(); await fs.rm(path.join(f.cwd, "saved.txt"));
const retained = await retainDirectory(f); await storage.deleteObject(f.companyId, retained.objectKey);
await db.update(issues).set({ status: "done" }).where(eq(issues.id, f.issueId));
const before = await workspace(f.workspaceId);
expect(await migrateRemoteWorkspace({ db, storage, ...f, apply: true })).toMatchObject({ applied: true });
const saved = await workspace(f.workspaceId), authority = readRemoteWorkspaceAuthority(saved.metadata)!;
expect(saved).toMatchObject({ status: "archived", closedAt: before.closedAt });
expect(saved.metadata?.isolatedTaskDirectoryRetention).toEqual(before.metadata?.isolatedTaskDirectoryRetention);
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({ status: "done", executionWorkspaceId: f.workspaceId });
expect(await fs.stat(f.cwd).catch(() => null)).toBeNull();
expect(await storage.headObject(f.companyId, retained.objectKey)).toMatchObject({ exists: false });
const ref = await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! });
const archivePath = path.join(root, "recovered-empty.pcws"), targetDir = path.join(root, "recovered-empty");
await loadWorkspaceSnapshotArchive({ storage, reference: ref!, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref!.manifestDigest });
expect(await fs.readdir(targetDir)).toEqual([]);
expect(await migrateRemoteWorkspace({ db, storage, ...f, apply: true })).toMatchObject({ applied: true, replayed: true });
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)?.snapshotId).toBe(authority.snapshotId);
});
it.each(["missing", "expired", "corrupt"])("does not replace an unavailable retained directory with an empty workspace (%s)", async failure => {
const f = await seed(), retained = await retainDirectory(f);
if (failure === "missing") await storage.deleteObject(f.companyId, retained.objectKey);
else {
const row = await workspace(f.workspaceId);
const retention = row.metadata!.isolatedTaskDirectoryRetention as Record<string, unknown>;
await db.update(executionWorkspaces).set({ metadata: { ...row.metadata, isolatedTaskDirectoryRetention: {
...retention, restorePoint: { ...retained, ...(failure === "expired" ? { expiredAt: new Date().toISOString() } : { sha256: "00".repeat(32) }) },
} } }).where(eq(executionWorkspaces.id, f.workspaceId));
}
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow();
const row = await workspace(f.workspaceId);
expect(row).toMatchObject({ status: "archived", providerType: "local_fs", cwd: f.cwd });
expect(readRemoteWorkspaceAuthority(row.metadata)).toBeNull();
expect(await fs.stat(f.cwd).catch(() => null)).toBeNull();
});
it("keeps newly restored local files when they appear during an archived import", async () => {
const f = await seed(); await retainDirectory(f);
const put = storage.putFile.bind(storage);
storage.putFile = async input => {
const result = await put(input);
await fs.mkdir(f.cwd); await fs.writeFile(path.join(f.cwd, "saved.txt"), "new local edits\n");
return result;
};
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("remote_workspace_migration_source_changed");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("new local edits\n");
});
it("preserves an archived workspace without reopening its task or losing its files", async () => {
const f = await seed(), closedAt = new Date("2026-10-20T12:00:00Z");
await db.update(executionWorkspaces).set({ status: "archived", closedAt }).where(eq(executionWorkspaces.id, f.workspaceId));
await db.update(issues).set({ status: "done" }).where(eq(issues.id, f.issueId));
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).resolves.toMatchObject({ applied: true });
const saved = await workspace(f.workspaceId), authority = readRemoteWorkspaceAuthority(saved.metadata)!;
expect(saved.status).toBe("archived"); expect(saved.closedAt).toEqual(closedAt);
const [issue] = await db.select().from(issues).where(eq(issues.id, f.issueId));
expect(issue).toMatchObject({ status: "done", executionWorkspaceId: f.workspaceId });
const ref = await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! });
await fs.rm(f.cwd, { recursive: true });
const archivePath = path.join(root, "archived.pcws"), targetDir = path.join(root, "restored-archived");
await loadWorkspaceSnapshotArchive({ storage, reference: ref!, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref!.manifestDigest });
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("original saved work\n");
});
it("imports a historical workspace without replacing the task's current workspace", async () => {
const f = await seed(), currentId = randomUUID(), currentCwd = path.join(root, "current");
await fs.mkdir(currentCwd); await fs.writeFile(path.join(currentCwd, "saved.txt"), "newer current work\n");
await db.insert(executionWorkspaces).values({ id: currentId, companyId: f.companyId, sourceIssueId: f.issueId,
mode: "isolated_workspace", strategyType: "project_primary", providerType: "local_fs", name: "Current workspace", cwd: currentCwd });
await db.update(issues).set({ executionWorkspaceId: currentId }).where(eq(issues.id, f.issueId));
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).resolves.toMatchObject({ applied: true });
const [issue] = await db.select().from(issues).where(eq(issues.id, f.issueId));
expect(issue?.executionWorkspaceId).toBe(currentId);
expect(await fs.readFile(path.join(currentCwd, "saved.txt"), "utf8")).toBe("newer current work\n");
expect(readRemoteWorkspaceAuthority((await workspace(currentId)).metadata)).toBeNull();
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)?.snapshotId).toBeTruthy();
});
it("refuses publication when the task changes its workspace during import", async () => {
const f = await seed(), put = storage.putFile.bind(storage);
storage.putFile = async input => {
const result = await put(input);
await db.update(issues).set({ executionWorkspaceId: null }).where(eq(issues.id, f.issueId));
return result;
};
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("remote_workspace_migration_source_changed");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("original saved work\n");
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("remote_workspace_migration_source_changed");
});
it("defaults to dry run and preserves both database authority and local files", async () => {
const f = await seed(); expect(await migrateRemoteWorkspace({ db, storage, ...f })).toMatchObject({ eligible: true, applied: false, alreadyMigrated: false });
expect((await workspace(f.workspaceId)).providerType).toBe("local_fs");
expect(await db.select().from(workspaceOperations)).toHaveLength(0);
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("original saved work\n");
});
it("verifies saved files, switches authority once, and retains the original folder", async () => {
const f = await seed();
expect(await migrateRemoteWorkspace({ db, storage, ...f, apply: true })).toMatchObject({ applied: true, alreadyMigrated: true });
const row = await workspace(f.workspaceId), authority = readRemoteWorkspaceAuthority(row.metadata);
expect(authority).toMatchObject({ epoch: 1, location: null, dirty: false, writerRunId: null });
expect(authority?.snapshotId).toMatch(/^[a-f0-9-]{36}$/);
expect(row.metadata?.retainedNote).toBe("keep"); expect(row.metadata?.remoteWorkspaceMigration).toBeUndefined();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("original saved work\n");
expect(await migrateRemoteWorkspace({ db, storage, ...f, apply: true })).toMatchObject({ replayed: true });
expect(await db.select().from(executionWorkspaceSnapshots)).toHaveLength(1);
expect(await db.select().from(activityLog)).toHaveLength(1);
});
it.each([false, true])("removes plaintext verification files with read-only folders after import (source changed: %s)", async sourceChanged => {
const f = await seed(), protectedDir = path.join(f.cwd, "protected"), scratchRoot = path.join(root, "scratch");
await fs.mkdir(protectedDir); await fs.mkdir(scratchRoot);
await fs.writeFile(path.join(protectedDir, "saved.txt"), "read-only saved work\n", { mode: 0o444 });
await fs.chmod(protectedDir, 0o555);
vi.stubEnv("TMPDIR", scratchRoot);
const get = storage.getObject.bind(storage); let reads = 0;
if (sourceChanged) storage.getObject = async (...args) => {
if (++reads === 2) await fs.writeFile(path.join(f.cwd, "saved.txt"), "new local work\n");
return get(...args);
};
try {
const migration = migrateRemoteWorkspace({ db, storage, ...f, apply: true });
if (sourceChanged) {
await expect(migration).rejects.toThrow("remote_workspace_migration_source_changed");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
} else {
await expect(migration).resolves.toMatchObject({ applied: true, alreadyMigrated: true });
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)?.snapshotId).toBeTruthy();
}
expect(await fs.readdir(scratchRoot)).toEqual([]);
expect((await fs.stat(protectedDir)).mode & 0o777).toBe(0o555);
expect((await fs.stat(path.join(protectedDir, "saved.txt"))).mode & 0o777).toBe(0o444);
expect(await fs.readFile(path.join(protectedDir, "saved.txt"), "utf8")).toBe("read-only saved work\n");
} finally {
await fs.chmod(protectedDir, 0o755);
await removeTaskDirectoryTree(scratchRoot);
}
});
it("resumes an interrupted upload with the original operation identity", async () => {
const f = await seed(), put = storage.putFile.bind(storage); let fail = true;
storage.putFile = async input => { const saved = await put(input); if (fail) { fail = false; throw new Error("upload reply lost"); } return saved; };
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("upload reply lost");
const before = await inventoryRemoteWorkspaceMigration(db, f); expect(before.operationId).not.toBeNull();
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await migrateRemoteWorkspace({ db, storage, ...f, apply: true })).toMatchObject({ applied: true });
const operations = await db.select().from(workspaceOperations); expect(operations).toHaveLength(1); expect(operations[0]?.id).toBe(before.operationId);
});
it("still rejects local source edits after an interrupted upload", async () => {
const f = await seed(), put = storage.putFile.bind(storage); let fail = true;
storage.putFile = async input => { const saved = await put(input); if (fail) { fail = false; throw new Error("upload reply lost"); } return saved; };
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("upload reply lost");
await fs.writeFile(path.join(f.cwd, "saved.txt"), "new local edits\n");
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("remote_workspace_snapshot_operation_content_changed");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("new local edits\n");
});
it("imports dirty unpushed Git work and restores it after the original folder disappears", async () => {
let f = await seed();
vi.stubEnv("PAPERCLIP_HOME", await fs.realpath(root));
const cwd = path.join(resolvePaperclipInstanceRoot(), "isolated-workspaces", f.companyId, f.issueId);
await fs.mkdir(path.dirname(cwd), { recursive: true }); await fs.rename(f.cwd, cwd);
await db.update(executionWorkspaces).set({ cwd, providerRef: cwd }).where(eq(executionWorkspaces.id, f.workspaceId));
f = { ...f, cwd };
await git(f.cwd, "init", "-q", "-b", "feature");
await git(f.cwd, "add", "."); await git(f.cwd, "commit", "-qm", "unpushed work");
await git(f.cwd, "remote", "add", "origin", "https://example.invalid/repository.git");
const head = await git(f.cwd, "rev-parse", "HEAD");
await fs.writeFile(path.join(f.cwd, "saved.txt"), "staged edit\n"); await git(f.cwd, "add", "saved.txt");
await fs.writeFile(path.join(f.cwd, "saved.txt"), "unstaged edit\n");
await db.update(executionWorkspaces).set({ repoUrl: "https://example.invalid/repository.git", branchName: "feature" }).where(eq(executionWorkspaces.id, f.workspaceId));
await migrateRemoteWorkspace({ db, storage, ...f, apply: true });
const authority = readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)!;
const ref = await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! });
await fs.rm(f.cwd, { recursive: true });
const archivePath = path.join(root, "cold.pcws"), targetDir = path.join(root, "cold");
await loadWorkspaceSnapshotArchive({ storage, reference: ref!, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref!.manifestDigest });
expect(await git(targetDir, "rev-parse", "HEAD")).toBe(head);
expect(await git(targetDir, "show", ":saved.txt")).toBe("staged edit");
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("unstaged edit\n");
});
it("blocks unresolved writers and explicitly parallel shared workspaces", async () => {
const f = await seed();
await db.update(executionWorkspaces).set({ mode: "shared_workspace" }).where(eq(executionWorkspaces.id, f.workspaceId));
await db.update(issues).set({ executionWorkspaceSettings: { sharedWorkspaceConcurrency: "allow" } }).where(eq(issues.id, f.issueId));
expect(await inventoryRemoteWorkspaceMigration(db, f)).toMatchObject({ eligible: false, reasons: ["parallel_shared_writers_unsupported"] });
await db.update(issues).set({ executionWorkspaceSettings: { sharedWorkspaceConcurrency: "serialize" } }).where(eq(issues.id, f.issueId));
await db.insert(workspaceOperations).values({ companyId: f.companyId, executionWorkspaceId: f.workspaceId, phase: "workspace_command", status: "running" });
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("workspace_operation_unresolved");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
});
it.each(["parallel", "orphan", "stale-binding"])("keeps offline enrollment blocked on unresolved workspace semantics (%s)", async mode => {
const f = await failedCopybackFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
await db.update(nativeRunFinalizations).set({ phase: "committed" }).where(eq(nativeRunFinalizations.runId, f.runId));
await db.update(heartbeatRuns).set({ runnerProfileJson: null }).where(eq(heartbeatRuns.id, f.runId));
if (mode === "parallel") {
await db.update(executionWorkspaces).set({ mode: "shared_workspace" }).where(eq(executionWorkspaces.id, f.workspaceId));
await db.update(issues).set({ executionWorkspaceSettings: { sharedWorkspaceConcurrency: "allow" } }).where(eq(issues.id, f.issueId));
} else if (mode === "orphan") await db.update(executionWorkspaces).set({ sourceIssueId: null }).where(eq(executionWorkspaces.id, f.workspaceId));
else await db.update(issues).set({ executionRunId: f.runId }).where(eq(issues.id, f.issueId));
const reason = mode === "parallel" ? "parallel_shared_writers_unsupported" : mode === "orphan" ? "task_binding_required" : "task_writer_bound";
await expect(migrateWorkspaceTrees(db, { storage, signal: new AbortController().signal, phase: () => {} })).rejects.toThrow(reason);
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("original saved work\n");
});
it.each(["running", "starting", "unknown", "failed"])("blocks a %s local service after its control lease expires", async status => {
const f = await seed(), serviceId = randomUUID();
await db.insert(executionWorkspaceRuntimeLeases).values({ companyId: f.companyId, executionWorkspaceId: f.workspaceId,
ownerKey: `issue:${f.issueId}`, ownerIssueId: f.issueId, lastAction: "start", expiresAt: new Date(Date.now() - 60_000) });
await db.insert(workspaceRuntimeServices).values({ id: serviceId, companyId: f.companyId, executionWorkspaceId: f.workspaceId,
scopeType: "execution_workspace", scopeId: f.workspaceId, serviceName: "Synthetic preview", status,
lifecycle: "shared", provider: "local_process", command: "node synthetic-preview.js", cwd: f.cwd });
expect(await inventoryRemoteWorkspaceMigration(db, f)).toMatchObject({ eligible: false, reasons: ["workspace_runtime_service_unsettled"] });
expect(await migrateRemoteWorkspace({ db, storage, ...f })).toMatchObject({ applied: false, eligible: false });
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("workspace_runtime_service_unsettled");
expect((await workspace(f.workspaceId)).providerType).toBe("local_fs");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await db.select().from(workspaceOperations)).toHaveLength(0);
expect(await db.select().from(executionWorkspaceSnapshots)).toHaveLength(0);
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("original saved work\n");
await db.$client`update workspace_runtime_services set status = 'stopped' where id = ${serviceId}::uuid`;
expect(await inventoryRemoteWorkspaceMigration(db, f)).toMatchObject({ eligible: true, reasons: [] });
});
it.each([{ repository: { phase: "cleanup" } }, "unrecognized-state"])("blocks an unresolved or malformed nested clone migration marker: %j", async marker => {
const f = await seed();
await db.update(executionWorkspaces).set({ metadata: { retainedNote: "keep", nestedCloneMigrationPending: marker } }).where(eq(executionWorkspaces.id, f.workspaceId));
expect(await inventoryRemoteWorkspaceMigration(db, f)).toMatchObject({ eligible: false, reasons: ["nested_clone_migration_unresolved"] });
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("nested_clone_migration_unresolved");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("original saved work\n");
});
it("blocks a running project service inherited by a shared workspace", async () => {
const f = await seed(), projectId = randomUUID(), projectWorkspaceId = randomUUID();
await db.insert(projects).values({ id: projectId, companyId: f.companyId, name: "Shared synthetic project" });
await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, companyId: f.companyId, projectId,
name: "Shared synthetic source", sourceType: "local", cwd: f.cwd });
await db.update(executionWorkspaces).set({ mode: "shared_workspace", projectId, projectWorkspaceId }).where(eq(executionWorkspaces.id, f.workspaceId));
await db.insert(workspaceRuntimeServices).values({ id: randomUUID(), companyId: f.companyId, projectWorkspaceId,
scopeType: "project_workspace", scopeId: projectWorkspaceId, serviceName: "Inherited preview", status: "running",
lifecycle: "shared", provider: "local_process", cwd: f.cwd });
expect(await inventoryRemoteWorkspaceMigration(db, f)).toMatchObject({ eligible: false, reasons: ["workspace_runtime_service_unsettled"] });
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("workspace_runtime_service_unsettled");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("original saved work\n");
});
it("rechecks a service that becomes unresolved during snapshot verification", async () => {
const f = await seed(), get = storage.getObject.bind(storage); let reads = 0;
storage.getObject = async (...args) => {
if (++reads === 2) await db.insert(workspaceRuntimeServices).values({ id: randomUUID(), companyId: f.companyId,
executionWorkspaceId: f.workspaceId, scopeType: "execution_workspace", scopeId: f.workspaceId,
serviceName: "Late synthetic preview", status: "running", lifecycle: "shared", provider: "local_process", cwd: f.cwd });
return get(...args);
};
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("workspace_runtime_service_unsettled");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("original saved work\n");
});
it("rechecks a writer that appears while the restored copy is being verified", async () => {
const f = await seed(), get = storage.getObject.bind(storage); let reads = 0;
storage.getObject = async (...args) => {
if (++reads === 2) await db.insert(workspaceOperations).values({ companyId: f.companyId, executionWorkspaceId: f.workspaceId,
phase: "workspace_command", status: "running" });
return get(...args);
};
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("workspace_operation_unresolved");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
});
it("preserves a local edit made during verification and refuses activation", async () => {
const f = await seed(), get = storage.getObject.bind(storage); let reads = 0;
storage.getObject = async (...args) => { if (++reads === 2) await fs.writeFile(path.join(f.cwd, "saved.txt"), "new local work\n"); return get(...args); };
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("source_changed");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("new local work\n");
});
});