Files
PaperClipAI/server/src/services/remote-workspace-migration.test.ts
T
DottaandPaperclip aa2a7be8b4 fix: recognize provider-owned Runner workspace storage (#20663)
## Thinking Path

> - Paperclip manages agent work and preserves its execution state.
> - A server replacement must retain every workspace that depends on the
old server disk.
> - Runner task environments can own their workspace outside the Core
server.
> - These environments save an adapter-managed workspace with a
runnerTaskWorkspaceOwner binding.
> - Readiness and maintenance treated that provider path as a Core
filesystem dependency.
> - This change validates the saved owner and shares that classification
between both paths.
> - Local, malformed, and foreign workspace bindings still block
enrollment.

## Linked Issues or Issue Description

Related enrollment safeguards: #18835. A GitHub search found no
duplicate fix for this provider-owned class.

**What happened?**

Volume-free readiness returned workspaces_require_migration for an
adapter-managed Runner workspace owned by a plugin task environment.
Maintenance also selected it and failed with
workspace_provider_unsupported. The provider owned the path, which did
not exist on the Core server.

**Expected behavior**

A valid provider-owned workspace does not require migration from the
Core server disk. Ordinary local workspaces and invalid ownership
bindings remain blockers.

**Steps to reproduce**

1. Run a paperclip_runner agent in a plugin task environment.
2. Retain its adapter-managed workspace and complete
runnerTaskWorkspaceOwner binding.
3. Read startup eligibility for volume-free enrollment.
4. Observe the workspace blocker before this fix.
5. Run workspace maintenance and observe workspace_provider_unsupported
for the same row.

**Paperclip version or commit**

Reproduced on 353945c86d. The regression
also fails on the branch base a3b34a9d88.

**Deployment mode**

Docker with external PostgreSQL. Local regression coverage uses
disposable PostgreSQL.

## What Changed

- Share one metadata predicate between startup eligibility and offline
workspace migration selection.
- Validate a complete saved owner against a same-company
paperclip_runner agent and a plugin environment with matching nonempty
keys. Per-run environment overrides remain supported.
- Keep ordinary local workspaces and invalid ownership bindings in the
migration set.
- Add real PostgreSQL coverage for provider-owned readiness, zero-import
maintenance selection, unchanged retained rows, and
local/foreign/malformed ownership controls.

## Verification

- Readiness RED: the provider-owned fixture failed with
workspaces_require_migration on unchanged source.
- Maintenance RED: the provider-owned fixture failed with
workspace_provider_unsupported before the shared selector was applied.
- GREEN: 29 selected tests passed across three files. This covers 8
eligibility/configuration tests and 21 migration/offline-admission
cases, including eleven invalid/local owner controls inside the
eligibility test. The affected migration file has 207 cases; broad
coverage remains with required CI.
- Commands: `pnpm exec vitest run
server/src/__tests__/ephemeral-server-data.test.ts
server/src/__tests__/ephemeral-server-contract.test.ts` plus focused
selections in `server/src/services/remote-workspace-migration.test.ts`
for provider ownership, offline admission, local imports,
interrupted-batch resume, and unresolved workspace semantics.
- Server layered typecheck passed after the final source change.
- Final head `2fb98dbc664da14e6f60d812e1e10877b2b7f00a`: [required
CI](https://github.com/paperclipai/paperclip/actions/runs/42920262773)
completed with 55 successful checks, 1 neutral review, and 4 intended
skips. Greptile and Paperclip Review both rated this head 5/5. No
unresolved review threads remain.
- No hosted data migration or metadata rewrite was performed.

## Risks

- This changes a readiness classification. Incomplete, foreign,
non-Runner, or mismatched environment bindings remain conservative
blockers.
- The check validates saved ownership through current database bindings.
It does not inspect provider files or grant execution authority.
- Existing saved rows retain their shape. No migration or archive format
changes are required.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository tools, code
editing, and real PostgreSQL tests. The host does not expose the exact
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-10 17:22:01 -06:00

2056 lines
174 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, sql } from "drizzle-orm";
import { activityLog, agents, environments, 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, migrateRemoteWorkspaceCohort } from "./remote-workspace-migration.js";
import { getWorkspaceSnapshot, loadWorkspaceSnapshotArchive } from "./remote-workspace-snapshot-storage.js";
import { restoreRemoteWorkspaceArchive } from "./remote-workspace-snapshot-archive.js";
import * as workspaceArchive 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";
import { withIndependentWorkspaceSourceLocks, withWorkspaceSourceLocks } from "./workspace-source-lock.js";
import { inspectOnlineWorkspaceSource } from "./online-workspace-source.js";
import { tryLockNativeWorkspaceFinalization } from "./native-runtime/native-workspace-finalization-ownership.js";
import { readMigratedLegacyWorkspaceSource } from "./legacy-workspace-recovery-migration.js";
import { nativeSha256 } from "./native-runtime/canonical.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 () => {
vi.restoreAllMocks();
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(environmentLeases); await db.delete(environments);
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]!; }
it("keeps provider-owned Runner task paths out of offline workspace migration", async () => {
vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
const companyId = randomUUID(), agentId = randomUUID(), environmentId = randomUUID();
const owner = { agentId, environmentId, pluginKey: "fixture.task-storage", driverKey: "tasks" };
await db.insert(companies).values({ id: companyId, name: "Provider-owned migration", issuePrefix: "PROV" });
await db.insert(environments).values({ id: environmentId, name: `Provider ${environmentId}`, driver: "plugin",
config: { pluginKey: owner.pluginKey, driverKey: owner.driverKey } });
await db.insert(agents).values({ id: agentId, companyId, name: "Provider Runner", adapterType: "paperclip_runner" });
const [issue] = await db.insert(issues).values({ companyId, title: "Provider task" }).returning();
const [retained] = await db.insert(executionWorkspaces).values({ companyId, sourceIssueId: issue.id,
name: "Provider path", mode: "shared_workspace", strategyType: "project_primary", providerType: "adapter_managed",
cwd: `/workspaces/provider-only-${randomUUID()}`, metadata: { runnerTaskWorkspaceOwner: owner } }).returning();
await db.update(issues).set({ executionWorkspaceId: retained.id }).where(eq(issues.id, issue.id));
const phase = vi.fn();
await expect(migrateWorkspaceTrees(db, { storage, signal: new AbortController().signal, phase })).resolves.toBe(0);
expect(phase).not.toHaveBeenCalled();
expect(await workspace(retained.id)).toEqual(retained);
await db.update(executionWorkspaces).set({ metadata: { runnerTaskWorkspaceOwner: {} } }).where(eq(executionWorkspaces.id, retained.id));
await expect(migrateWorkspaceTrees(db, { storage, signal: new AbortController().signal, phase }))
.rejects.toThrow("workspace_provider_unsupported");
});
async function queuedFixture() {
const original = await seed();
vi.stubEnv("PAPERCLIP_HOME", await fs.realpath(root));
const cwd = path.join(resolvePaperclipInstanceRoot(), "isolated-workspaces", original.companyId, original.issueId);
await fs.mkdir(path.dirname(cwd), { recursive: true }); await fs.rename(original.cwd, cwd);
await db.update(executionWorkspaces).set({ cwd, providerRef: cwd }).where(eq(executionWorkspaces.id, original.workspaceId));
return { ...original, cwd };
}
async function sourceWriter(companyId: string, issueIds: string[]) {
let entered!: () => void, release!: () => void;
const ready = new Promise<void>(resolve => { entered = resolve; });
const held = new Promise<void>(resolve => { release = resolve; });
const done = withIndependentWorkspaceSourceLocks(db, { companyId, issueIds }, async () => { entered(); await held; });
await ready;
return { release, done };
}
const queuedBackends = async () => db.execute(sql`select pid from pg_stat_activity where datname=current_database()
and wait_event_type='Lock' and query like 'select pg_advisory_xact_lock%'`);
it.each(["single", "cohort"] as const)("copies once without a readback during %s migration", async kind => {
const f = await queuedFixture();
const capture = vi.spyOn(workspaceArchive, "captureRemoteWorkspaceDirectory");
const get = vi.spyOn(storage, "getObject").mockRejectedValue(new Error("migration must not download"));
const put = vi.spyOn(storage, "putFile");
try {
const migrate = () => kind === "single" ? migrateRemoteWorkspace({ db, storage, ...f, apply: true })
: migrateRemoteWorkspaceCohort({ db, storage, companyId: f.companyId, workspaceIds: [f.workspaceId],
apply: true, signal: AbortSignal.timeout(10_000) });
expect(await migrate()).toMatchObject({ applied: true });
expect(await migrate()).toMatchObject({ applied: true, replayed: true });
expect(capture).toHaveBeenCalledTimes(1);
expect(capture.mock.calls[0][0]).toMatchObject({ singlePass: true });
expect(put).toHaveBeenCalledTimes(1); expect(get).not.toHaveBeenCalled();
get.mockRestore();
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 fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("original saved work\n");
} finally { capture.mockRestore(); get.mockRestore(); put.mockRestore(); }
});
it("reuses an uploaded archive after its acknowledgement is lost without another capture or download", async () => {
const f = await queuedFixture(), upload = storage.putFile.bind(storage);
const capture = vi.spyOn(workspaceArchive, "captureRemoteWorkspaceDirectory");
const get = vi.spyOn(storage, "getObject").mockRejectedValue(new Error("migration must not download"));
const put = vi.spyOn(storage, "putFile").mockImplementation(async input => { await upload(input); throw new Error("lost acknowledgement"); });
try {
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("lost acknowledgement");
put.mockImplementation(upload);
expect(await migrateRemoteWorkspace({ db, storage, ...f, apply: true })).toMatchObject({ applied: true });
expect(capture).toHaveBeenCalledTimes(1); expect(put).toHaveBeenCalledTimes(1); expect(get).not.toHaveBeenCalled();
} finally { capture.mockRestore(); get.mockRestore(); put.mockRestore(); }
});
it.each(["single", "cohort"] as const)("commits a queued %s import after the finite source writer releases", async kind => {
const f = await queuedFixture(), writer = await sourceWriter(f.companyId, [f.issueId]);
let settled = false;
const importing = (kind === "single"
? migrateRemoteWorkspace({ db, storage, ...f, apply: true, sourceLockWaitMs: 5000 })
: migrateRemoteWorkspaceCohort({ db, storage, companyId: f.companyId, workspaceIds: [f.workspaceId],
apply: true, signal: AbortSignal.timeout(10_000), sourceLockWaitMs: 5000 }))
.then(value => ({ value }), error => ({ error })).finally(() => { settled = true; });
try {
await expect.poll(async () => (await queuedBackends()).length, { timeout: 2000 }).toBe(1);
expect(settled).toBe(false);
expect(await db.select().from(workspaceOperations)).toHaveLength(0);
writer.release(); await writer.done;
expect(await importing).toMatchObject({ value: { applied: true } });
expect(await db.select().from(workspaceOperations)).toEqual([expect.objectContaining({ status: "succeeded" })]);
expect(await db.select().from(executionWorkspaceSnapshots)).toEqual([expect.objectContaining({ status: "committed" })]);
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).not.toBeNull();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("original saved work\n");
} finally { writer.release(); await writer.done; await importing; }
});
it.each(["timeout", "abort"] as const)("leaves the original and journal intact on queued admission %s", async mode => {
const f = await queuedFixture(), before = await workspace(f.workspaceId);
const writer = await sourceWriter(f.companyId, [f.issueId]), abort = new AbortController(), reason = new Error("cancel queued import");
const importing = migrateRemoteWorkspace({ db, storage, ...f, apply: true, sourceLockWaitMs: mode === "timeout" ? 300 : 5000,
signal: abort.signal }).then(value => ({ value }), error => ({ error }));
try {
await expect.poll(async () => (await queuedBackends()).length, { timeout: 2000 }).toBe(1);
const cancelledAt = performance.now();
if (mode === "abort") abort.abort(reason);
expect(await importing).toMatchObject({ error: mode === "abort" ? reason : expect.objectContaining({ message: "workspace_source_busy" }) });
expect(performance.now() - cancelledAt).toBeLessThan(2000);
expect(await workspace(f.workspaceId)).toEqual(before);
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");
expect(await queuedBackends()).toHaveLength(0);
} finally { writer.release(); await writer.done; await importing; }
await expect(withIndependentWorkspaceSourceLocks(db, { companyId: f.companyId, issueIds: [f.issueId] }, async () => true, true)).resolves.toBe(true);
});
it("bounds the whole source queue and releases keys acquired before a later timeout or abort", async () => {
for (const mode of ["timeout", "abort"] as const) {
const companyId = randomUUID(), issueIds = [randomUUID(), randomUUID()].sort();
const first = await sourceWriter(companyId, [issueIds[0]]), second = await sourceWriter(companyId, [issueIds[1]]);
const abort = new AbortController(), reason = new Error("cancel partial admission"), startedAt = performance.now();
const waiting = withIndependentWorkspaceSourceLocks(db, { companyId, issueIds }, async () => "unexpected", true,
{ waitMs: mode === "timeout" ? 600 : 5000, signal: abort.signal }).then(value => ({ value }), error => ({ error }));
try {
await expect.poll(async () => (await queuedBackends()).length).toBe(1);
if (mode === "timeout") await new Promise(resolve => setTimeout(resolve, 350));
first.release(); await first.done;
await expect.poll(async () => (await queuedBackends()).length).toBe(1);
await expect.poll(() => withIndependentWorkspaceSourceLocks(db, { companyId, issueIds: [issueIds[0]] }, async () => false, true)
.catch(() => true)).toBe(true);
if (mode === "abort") abort.abort(reason);
expect(await waiting).toMatchObject({ error: mode === "abort" ? reason : expect.objectContaining({ message: "workspace_source_busy" }) });
expect(performance.now() - startedAt).toBeLessThan(mode === "timeout" ? 850 : 2000);
await expect(withIndependentWorkspaceSourceLocks(db, { companyId, issueIds: [issueIds[0]] }, async () => true, true)).resolves.toBe(true);
} finally { first.release(); second.release(); await Promise.all([first.done, second.done, waiting]); }
}
});
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.each(["offline", "online", "failed"] as const)("preserves an unmaterialized repository request during %s migration", async mode => {
const f = await (mode === "offline" ? seed() : queuedFixture());
const pending = await pendingRepository(f);
if (mode === "failed") await db.update(executionWorkspaceRepositories)
.set({ state: "needs_repair", failureCode: "repository_preparation_failed", catalogRepositoryId: "unavailable-catalog",
requestedRef: "preserved-requested-ref" }).where(eq(executionWorkspaceRepositories.id, pending.id));
const [repository] = await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.id, pending.id));
vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
await migrateRemoteWorkspace({ db, storage, ...f, apply: true, writersStopped: mode === "offline" });
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 (state === "online" ? seed() : queuedFixture()), 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 }))
.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");
});
it("keeps a companion's pending repository request blocked during online migration", async () => {
const f = await queuedFixture(), aliasId = randomUUID();
await db.insert(executionWorkspaces).values({ ...await workspace(f.workspaceId), id: aliasId });
await pendingRepository({ ...f, workspaceId: aliasId });
vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true }))
.rejects.toThrow("repository_preparation_unresolved");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await db.select().from(workspaceOperations)).toHaveLength(0);
});
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(existing?: Awaited<ReturnType<typeof seed>>) {
const f = existing ?? 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" } as const;
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"])("trusts committed nested references while retaining cancellation (%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; };
}
const get = vi.spyOn(storage, "getObject"), capture = vi.spyOn(workspaceArchive, "captureRemoteWorkspaceDirectory");
const migration = migrateRemoteWorkspace({ db, storage, ...parent, apply: true, writersStopped: true, signal: controller.signal });
if (mode === "abort") {
await expect(migration).rejects.toThrow();
expect(readRemoteWorkspaceAuthority((await workspace(parent.workspaceId)).metadata)).toBeNull();
} else {
await expect(migration).resolves.toMatchObject({ applied: true });
const parentAuthority = readRemoteWorkspaceAuthority((await workspace(parent.workspaceId)).metadata)!;
const [parentReference] = await db.select().from(executionWorkspaceSnapshots).where(eq(executionWorkspaceSnapshots.id, parentAuthority.snapshotId!));
expect(parentReference.metadata?.importedWorkspaceChildren).toEqual([expect.objectContaining({ workspaceId: child.workspaceId,
snapshotId: reference!.id, manifestDigest: reference!.manifestDigest })]);
}
expect(get).not.toHaveBeenCalled();
expect(capture).toHaveBeenCalledTimes(1);
expect(capture.mock.calls[0][0]).toMatchObject({ cwd: parent.cwd, singlePass: true });
expect(readRemoteWorkspaceAuthority((await workspace(child.workspaceId)).metadata)).toEqual(authority);
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("retains transitive committed nested references without recapturing changed child bytes", 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 });
const grandchildAuthority = readRemoteWorkspaceAuthority((await workspace(id)).metadata)!;
await migrateRemoteWorkspace({ db, storage, ...child, apply: true, writersStopped: true });
const childAuthority = readRemoteWorkspaceAuthority((await workspace(child.workspaceId)).metadata)!;
await fs.writeFile(path.join(cwd, "deep.txt"), "new deep work");
const get = vi.spyOn(storage, "getObject"), capture = vi.spyOn(workspaceArchive, "captureRemoteWorkspaceDirectory");
await expect(migrateRemoteWorkspace({ db, storage, ...parent, apply: true, writersStopped: true })).resolves.toMatchObject({ applied: true });
expect(readRemoteWorkspaceAuthority((await workspace(parent.workspaceId)).metadata)?.snapshotId).toBeTruthy();
expect(readRemoteWorkspaceAuthority((await workspace(child.workspaceId)).metadata)).toEqual(childAuthority);
expect(readRemoteWorkspaceAuthority((await workspace(id)).metadata)).toEqual(grandchildAuthority);
expect(await fs.readFile(path.join(cwd, "deep.txt"), "utf8")).toBe("new deep work");
expect(get).not.toHaveBeenCalled(); expect(capture).toHaveBeenCalledTimes(1);
});
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 };
}
async function archivedPreparedFixture(sourceKind: "recipe" | "cleanup" | "branch" = "recipe",
beforeArchive?: (f: Awaited<ReturnType<typeof gitArchiveFixture>>) => Promise<void>) {
const base = await gitArchiveFixture(), f = { ...base, ...await failedCopybackFixture(base) };
let cleanup: typeof workspaceOperations.$inferSelect | null = null;
if (sourceKind === "recipe") {
await fs.writeFile(path.join(f.cwd, "saved.txt"), "archived staged edit\n"); await git(f.cwd, "add", "saved.txt");
await fs.writeFile(path.join(f.cwd, "saved.txt"), "archived unstaged edit\n");
await fs.writeFile(path.join(f.cwd, "untracked.txt"), "archived private file\n");
await beforeArchive?.(f);
const captured = (await captureRestorePoint({ cwd: f.cwd, upstreamCommits: [f.upstream] }))!;
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: "saved.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 };
const contents = await captureGitWorkspaceContents(f.cwd, { companyId: f.companyId, workspaceId: f.workspaceId }, []);
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);
} else {
await db.update(executionWorkspaces).set({ status: "archived", closedAt: new Date() }).where(eq(executionWorkspaces.id, f.workspaceId));
if (sourceKind === "cleanup") await git(f.base, "merge", "--ff-only", f.branchName);
await git(f.base, "worktree", "remove", f.cwd);
if (sourceKind === "cleanup") {
const receipt = await git(f.base, "branch", "-d", f.branchName);
[cleanup] = await db.insert(workspaceOperations).values({ companyId: f.companyId, executionWorkspaceId: f.workspaceId,
phase: "worktree_cleanup", status: "succeeded", exitCode: 0, command: `git branch -d ${f.branchName}`,
cwd: f.base, stdoutExcerpt: receipt, startedAt: new Date(), finishedAt: new Date(),
metadata: { cleanupAction: "branch_delete", workspaceId: f.workspaceId, workspacePath: f.cwd, branchName: f.branchName } }).returning();
}
}
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
const [environment] = await db.insert(environments).values({ name: f.runId, driver: "daytona" }).returning();
const [lease] = await db.insert(environmentLeases).values({ id: f.reference.leaseId, companyId: f.companyId,
environmentId: environment.id, heartbeatRunId: f.runId, issueId: f.issueId, executionWorkspaceId: f.workspaceId,
provider: "daytona", providerLeaseId: f.reference.providerLeaseId, status: "retained", leasePolicy: "retain_on_failure",
metadata: { remoteCwd: f.reference.remoteCwd } }).returning();
const cause = "native_workspace_sync_out_unrecoverable";
await db.update(nativeRunFinalizations).set({ failureCode: cause }).where(eq(nativeRunFinalizations.runId, f.runId));
const [hold] = await db.insert(issueRecoveryActions).values({ companyId: f.companyId, sourceIssueId: f.issueId,
kind: "active_run_watchdog", cause, ownerType: "board", ownerAgentId: null, returnOwnerAgentId: run.agentId,
fingerprint: nativeSha256({ runId: f.runId, failureCode: cause }), nextAction: "Preserve retained remote files", evidence: { runId: f.runId } }).returning();
if (sourceKind === "recipe") {
const [selected] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: f.issueId,
name: "Current task workspace", providerType: "local_fs", mode: "isolated_workspace", strategyType: "project_primary", cwd: path.join(root, "current-task") }).returning();
await db.update(issues).set({ status: "cancelled", executionWorkspaceId: selected.id }).where(eq(issues.id, f.issueId));
} else await db.update(issues).set({ status: "done" }).where(eq(issues.id, f.issueId));
await db.insert(activityLog).values({ companyId: f.companyId, actorType: "user", actorId: "original owner",
action: "issue.updated", entityType: "issue", entityId: f.issueId, details: { retained: "original task history" } });
const [task] = await db.select().from(issues).where(eq(issues.id, f.issueId));
const [owner] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
const history = await db.select().from(activityLog).where(eq(activityLog.entityId, f.issueId));
return { ...f, run, lease, hold, task, owner, history, cleanup, before: await workspace(f.workspaceId) };
}
async function archivedRepositoryFixture(layout: "absent" | "partial" | "symlink" = "absent", state: "pending" | "needs_repair" = "needs_repair") {
const relativePath = layout === "partial" ? "pending-repository" : layout === "symlink" ? "pending-parent/missing" : ".paperclip-repositories/pending";
const f = await archivedPreparedFixture("recipe", async f => {
if (layout === "partial") { await fs.mkdir(path.join(f.cwd, relativePath)); await fs.writeFile(path.join(f.cwd, relativePath, "partial.txt"), "retained partial files\n"); }
if (layout === "symlink") { await fs.mkdir(path.join(f.cwd, "saved-target")); await fs.writeFile(path.join(f.cwd, "saved-target", "keep.txt"), "saved target\n");
await fs.symlink("saved-target", path.join(f.cwd, "pending-parent")); }
});
const pending = await pendingRepository(f);
const [repository] = await db.update(executionWorkspaceRepositories).set({ state, relativePath,
failureCode: state === "needs_repair" ? "repository_preparation_failed" : null }).where(eq(executionWorkspaceRepositories.id, pending.id)).returning();
const [task] = await db.update(issues).set({ status: "done" }).where(eq(issues.id, f.issueId)).returning();
return { ...f, repository, task };
}
it.each(["pending", "needs_repair"] as const)("preserves an archived repository intent after authenticated absence proof (%s)", async state => {
const f = await archivedRepositoryFixture("absent", state);
expect(await migrateRemoteWorkspace({ db, storage, ...f, apply: true })).toMatchObject({ applied: true });
const row = await workspace(f.workspaceId), authority = readRemoteWorkspaceAuthority(row.metadata)!;
expect(row.status).toBe("archived"); expect(row.closedAt).toEqual(f.before.closedAt);
expect(row.metadata?.gitCacheRestoration).toEqual(f.before.metadata?.gitCacheRestoration);
expect((await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.id, f.repository.id)))[0]).toEqual(f.repository);
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toEqual(f.task);
expect(await db.select().from(activityLog).where(eq(activityLog.entityId, f.issueId))).toEqual(f.history);
expect((await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.lease.id)))[0]).toEqual(f.lease);
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0]).toEqual(f.owner);
await expect(readMigratedLegacyWorkspaceSource(db, { companyId: f.companyId, runId: f.runId, reference: f.reference })).resolves.toMatchObject({ reference: f.reference });
const ref = (await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! }))!, archivePath = path.join(root, "intent.pcws"), targetDir = path.join(root, "restored-intent");
await loadWorkspaceSnapshotArchive({ storage, reference: ref, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref.manifestDigest });
expect(await fs.readFile(path.join(targetDir, "untracked.txt"), "utf8")).toBe("archived private file\n");
await expect(fs.lstat(path.join(targetDir, f.repository.relativePath))).rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.access(f.cwd)).rejects.toMatchObject({ code: "ENOENT" });
});
it.each(["partial", "symlink"] as const)("refuses an archived repository intent with saved %s state before upload", async layout => {
const f = await archivedRepositoryFixture(layout), put = storage.putFile.bind(storage); let uploads = 0;
storage.putFile = async input => { uploads++; return put(input); };
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("repository_preparation_unresolved");
expect(uploads).toBe(0);
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect((await workspace(f.workspaceId)).metadata?.gitCacheRestoration).toEqual(f.before.metadata?.gitCacheRestoration);
expect((await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.id, f.repository.id)))[0]).toEqual(f.repository);
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toEqual(f.task);
});
it("refuses an archived repository intent changed to ready during upload", async () => {
const f = await archivedRepositoryFixture(), put = storage.putFile.bind(storage); let uploaded = false;
storage.putFile = async input => { const saved = await put(input); uploaded = true;
await db.update(executionWorkspaceRepositories).set({ state: "ready", pinnedCommit: "b".repeat(40) }).where(eq(executionWorkspaceRepositories.id, f.repository.id)); return saved; };
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("remote_workspace_migration_source_changed");
expect(uploaded).toBe(true); expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toEqual(f.task);
});
it("preserves archived repository intent metadata without restoring an uploaded archive", async () => {
const f = await archivedRepositoryFixture(), restore = vi.spyOn(workspaceArchive, "restoreRemoteWorkspaceArchive");
const capture = vi.spyOn(workspaceArchive, "captureRemoteWorkspaceDirectory");
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).resolves.toMatchObject({ applied: true });
expect(restore).not.toHaveBeenCalled(); expect(capture).toHaveBeenCalledTimes(1);
expect(capture.mock.calls[0][0]).toMatchObject({ singlePass: true });
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)?.snapshotId).toBeTruthy();
expect((await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.id, f.repository.id)))[0]).toEqual(f.repository);
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toEqual(f.task);
expect((await workspace(f.workspaceId)).metadata?.gitCacheRestoration).toEqual(f.before.metadata?.gitCacheRestoration);
});
it("rechecks the current archived repository intent path at publication", async () => {
const f = await archivedRepositoryFixture(), put = storage.putFile.bind(storage); let uploaded = false;
storage.putFile = async input => { const saved = await put(input); uploaded = true;
await db.update(executionWorkspaceRepositories).set({ relativePath: "untracked.txt" }).where(eq(executionWorkspaceRepositories.id, f.repository.id)); return saved; };
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("remote_workspace_migration_source_changed");
expect(uploaded).toBe(true); expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toEqual(f.task);
expect((await workspace(f.workspaceId)).metadata?.gitCacheRestoration).toEqual(f.before.metadata?.gitCacheRestoration);
});
it("rechecks an archived repository intent against a recovered uploaded archive", async () => {
const f = await archivedPreparedFixture(), transaction = db.transaction.bind(db); let interrupted = false;
const fault = vi.spyOn(db, "transaction").mockImplementation(async (action, config) => {
const result = await transaction(action, config);
if (!interrupted && result && typeof result === "object" && "status" in result && result.status === "uploaded") {
interrupted = true; throw new Error("uploaded checkpoint reply lost");
}
return result;
});
try { await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("uploaded checkpoint reply lost"); }
finally { fault.mockRestore(); }
expect(interrupted).toBe(true);
const [snapshot] = await db.select().from(executionWorkspaceSnapshots); expect(snapshot.status).toBe("uploaded");
const pending = await pendingRepository(f);
const [repository] = await db.update(executionWorkspaceRepositories).set({ relativePath: "untracked.txt" }).where(eq(executionWorkspaceRepositories.id, pending.id)).returning();
await fs.rm(f.base, { recursive: true });
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 db.select().from(executionWorkspaceSnapshots))[0]).toMatchObject({ id: snapshot.id, status: "uploaded" });
expect((await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.id, repository.id)))[0]).toEqual(repository);
});
it("resumes a later archived upload after an earlier missing attempt with a different manifest", async () => {
const f = await archivedPreparedFixture(), upload = storage.putFile.bind(storage);
const interrupted = vi.spyOn(storage, "putFile").mockImplementationOnce(async input => {
await upload(input); throw new Error("later archived upload reply lost");
});
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("later archived upload reply lost");
interrupted.mockRestore();
const [pending] = await db.select().from(executionWorkspaceSnapshots);
expect(pending.status).toBe("pending");
const [existing] = pending.metadata!.uploads as Array<{ objectKey: string; manifestDigest: string; [key: string]: unknown }>;
const missing = { ...existing, objectKey: `${existing.objectKey}.missing`, manifestDigest: "00".repeat(32) };
expect(missing.manifestDigest).not.toBe(existing.manifestDigest);
expect(await storage.headObject(f.companyId, missing.objectKey)).toMatchObject({ exists: false });
expect(await storage.headObject(f.companyId, existing.objectKey)).toMatchObject({ exists: true });
const attempts = [missing, existing];
await db.update(executionWorkspaceSnapshots).set({ metadata: { ...pending.metadata, uploads: attempts } })
.where(eq(executionWorkspaceSnapshots.id, pending.id));
const capture = vi.spyOn(workspaceArchive, "captureRemoteWorkspaceDirectory");
const get = vi.spyOn(storage, "getObject").mockRejectedValue(new Error("resume must not download"));
const put = vi.spyOn(storage, "putFile");
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).resolves.toMatchObject({ applied: true });
expect(capture).not.toHaveBeenCalled(); expect(get).not.toHaveBeenCalled(); expect(put).not.toHaveBeenCalled();
const [saved] = await db.select().from(executionWorkspaceSnapshots);
expect(saved).toMatchObject({ id: pending.id, sourceOperationId: pending.sourceOperationId, status: "committed",
objectKey: existing.objectKey, manifestDigest: existing.manifestDigest, metadata: { uploads: attempts } });
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)?.snapshotId).toBe(pending.id);
});
it("refuses an old archived journal with unknown nonempty repository intent bindings before resume work", async () => {
const f = await archivedRepositoryFixture(), upload = storage.putFile.bind(storage);
const interrupted = vi.spyOn(storage, "putFile").mockImplementationOnce(async input => {
await upload(input); throw new Error("old archived upload reply lost");
});
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("old archived upload reply lost");
interrupted.mockRestore();
const [operation] = await db.select().from(workspaceOperations).where(eq(workspaceOperations.phase, "remote_workspace_migration"));
const metadata = { ...operation.metadata }; delete metadata.archivedRepositoryIntents;
await db.update(workspaceOperations).set({ metadata }).where(eq(workspaceOperations.id, operation.id));
await db.update(executionWorkspaceRepositories).set({ relativePath: "untracked.txt" })
.where(eq(executionWorkspaceRepositories.id, f.repository.id));
const journals = await db.select().from(workspaceOperations), snapshots = await db.select().from(executionWorkspaceSnapshots);
const before = await workspace(f.workspaceId), repositories = await db.select().from(executionWorkspaceRepositories);
const baseBytes = await fs.readFile(path.join(f.base, "base.txt"));
const capture = vi.spyOn(workspaceArchive, "captureRemoteWorkspaceDirectory");
const get = vi.spyOn(storage, "getObject"), put = vi.spyOn(storage, "putFile"), head = vi.spyOn(storage, "headObject");
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("remote_workspace_migration_source_changed");
expect(capture).not.toHaveBeenCalled(); expect(get).not.toHaveBeenCalled(); expect(put).not.toHaveBeenCalled(); expect(head).not.toHaveBeenCalled();
expect(await db.select().from(workspaceOperations)).toEqual(journals);
expect(await db.select().from(executionWorkspaceSnapshots)).toEqual(snapshots);
expect(await workspace(f.workspaceId)).toEqual(before);
expect(await db.select().from(executionWorkspaceRepositories)).toEqual(repositories);
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toEqual(f.task);
expect(await fs.readFile(path.join(f.base, "base.txt"))).toEqual(baseBytes);
await expect(fs.access(f.cwd)).rejects.toMatchObject({ code: "ENOENT" });
});
it.each(["cleanup", "branch"] as const)("preserves a no-recipe archived %s source with prepared recovery", async kind => {
const f = await archivedPreparedFixture(kind);
expect(await migrateRemoteWorkspace({ db, storage, ...f, apply: true })).toMatchObject({ applied: true });
const row = await workspace(f.workspaceId), authority = readRemoteWorkspaceAuthority(row.metadata)!;
expect(row.status).toBe("archived"); expect(row.closedAt).toEqual(f.before.closedAt);
expect(row.metadata).not.toHaveProperty("gitCacheRestoration");
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toEqual(f.task);
expect(await db.select().from(activityLog).where(eq(activityLog.entityId, f.issueId))).toEqual(f.history);
expect((await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.lease.id)))[0]).toEqual(f.lease);
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0]).toEqual(f.owner);
expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.hold.id)))[0]).toEqual(f.hold);
await expect(readMigratedLegacyWorkspaceSource(db, { companyId: f.companyId, runId: f.runId, reference: f.reference }))
.resolves.toMatchObject({ runId: f.runId, reference: f.reference });
if (f.cleanup) expect((await db.select().from(workspaceOperations).where(eq(workspaceOperations.id, f.cleanup.id)))[0]).toEqual(f.cleanup);
else expect(await git(f.base, "rev-parse", `refs/heads/${f.branchName}`)).toBe(f.commit);
await expect(fs.access(f.cwd)).rejects.toMatchObject({ code: "ENOENT" });
const ref = (await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! }))!;
const archivePath = path.join(root, "no-recipe.pcws"), targetDir = path.join(root, "restored-no-recipe");
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");
});
it.each(["cleanup", "branch"] as const)("refuses a no-recipe archived %s source changed during upload", async kind => {
const f = await archivedPreparedFixture(kind), put = storage.putFile.bind(storage); let uploaded = false;
storage.putFile = async input => { const saved = await put(input); uploaded = true;
if (f.cleanup) await db.update(workspaceOperations).set({ status: "failed" }).where(eq(workspaceOperations.id, f.cleanup.id));
else await git(f.base, "update-ref", `refs/heads/${f.branchName}`, f.upstream);
return saved; };
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow();
expect(uploaded).toBe(true);
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toEqual(f.task);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0]).toEqual(f.run);
expect((await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.lease.id)))[0]).toEqual(f.lease);
await expect(fs.access(f.cwd)).rejects.toMatchObject({ code: "ENOENT" });
});
it("preserves an unselected archived recipe and its pending native files without reopening the task", async () => {
const f = await archivedPreparedFixture(), put = storage.putFile.bind(storage);
let interrupt = true;
storage.putFile = async input => { const saved = await put(input);
if (interrupt) { interrupt = false; throw new Error("archived upload reply lost"); } return saved; };
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("archived upload reply lost");
const [interrupted] = await db.select().from(workspaceOperations).where(eq(workspaceOperations.phase, "remote_workspace_migration"));
const [pending] = await db.select().from(executionWorkspaceSnapshots);
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await migrateRemoteWorkspace({ db, storage, ...f, apply: true })).toMatchObject({ applied: true });
const saved = await workspace(f.workspaceId), authority = readRemoteWorkspaceAuthority(saved.metadata)!;
expect(saved.status).toBe("archived"); expect(saved.closedAt).toEqual(f.before.closedAt);
expect(authority.snapshotId).toBe(pending.id);
expect((await db.select().from(workspaceOperations).where(eq(workspaceOperations.id, interrupted.id)))[0].status).toBe("succeeded");
expect(saved.metadata?.gitCacheRestoration).toEqual(f.before.metadata?.gitCacheRestoration);
expect(saved.metadata?.remoteWorkspaceRecoverySourceRunIds).toEqual([f.runId]);
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toEqual(f.task);
expect(await db.select().from(activityLog).where(eq(activityLog.entityId, f.issueId))).toEqual(f.history);
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0]).toEqual(f.owner);
expect((await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.lease.id)))[0]).toEqual(f.lease);
expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.hold.id)))[0]).toEqual(f.hold);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
const marker = { schema: "paperclip.legacy-workspace-recovery-migration.v1", operationId: expect.any(String), workspaceId: f.workspaceId };
expect(run).toEqual({ ...f.run, updatedAt: expect.any(Date), runnerProfileJson: { ...f.run.runnerProfileJson, legacyWorkspaceRecoveryMigration: marker } });
await expect(readMigratedLegacyWorkspaceSource(db, { companyId: f.companyId, runId: f.runId,
reference: f.reference })).resolves.toMatchObject({ runId: f.runId, reference: f.reference });
await expect(fs.access(f.cwd)).rejects.toMatchObject({ code: "ENOENT" });
const ref = (await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! }))!;
const archivePath = path.join(root, "archived-native.pcws"), targetDir = path.join(root, "restored-native");
await loadWorkspaceSnapshotArchive({ storage, reference: ref, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref.manifestDigest });
expect(await git(targetDir, "show", ":saved.txt")).toBe("archived staged edit");
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("archived unstaged edit\n");
expect(await fs.readFile(path.join(targetDir, "untracked.txt"), "utf8")).toBe("archived private file\n");
await migrateRemoteWorkspace({ db, storage, ...f, apply: true });
expect((await db.select().from(workspaceOperations).where(eq(workspaceOperations.phase, "remote_workspace_migration")))).toHaveLength(1);
});
it.each(["recipe", "reference", "provider"])("refuses an archived prepared source changed during upload (%s)", async change => {
const f = await archivedPreparedFixture(), put = storage.putFile.bind(storage);
let uploaded = false;
storage.putFile = async input => {
const saved = await put(input); uploaded = true;
if (change === "recipe") await db.update(executionWorkspaces).set({ metadata: { ...f.before.metadata,
remoteWorkspaceMigration: (await workspace(f.workspaceId)).metadata?.remoteWorkspaceMigration,
gitCacheRestoration: { ...f.before.metadata!.gitCacheRestoration as object, commit: f.upstream } } }).where(eq(executionWorkspaces.id, f.workspaceId));
else {
const reference = { ...f.reference, ...(change === "reference" ? { baselineSha256: "56".repeat(32) } : { providerLeaseId: randomUUID() }) };
if (change === "provider") await db.update(environmentLeases).set({ providerLeaseId: reference.providerLeaseId }).where(eq(environmentLeases.id, f.lease.id));
await db.update(heartbeatRuns).set({ runnerProfileJson: { ...f.run.runnerProfileJson, nativeWorkspaceSync: reference } }).where(eq(heartbeatRuns.id, f.runId));
}
return saved;
};
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow();
expect(uploaded).toBe(true);
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toEqual(f.task);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0].runnerProfileJson).not.toHaveProperty("legacyWorkspaceRecoveryMigration");
await expect(fs.access(f.cwd)).rejects.toMatchObject({ code: "ENOENT" });
});
it("refuses a competing native F owner before capturing an archived prepared source", async () => {
const f = await archivedPreparedFixture();
let release!: () => void, entered!: () => void;
const held = new Promise<void>(done => { release = done; }), ready = new Promise<void>(done => { entered = done; });
const owner = db.transaction(async tx => { expect(await tryLockNativeWorkspaceFinalization(tx as unknown as typeof db,
{ companyId: f.companyId, runId: f.runId })).toBe(true); entered(); await held; });
await ready;
try {
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("remote_copyback_owned");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await db.select().from(executionWorkspaceSnapshots)).toHaveLength(0);
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0]).toEqual(f.owner);
} finally { release(); await owner; }
});
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 transaction = db.transaction.bind(db); let interrupted = false;
const fault = vi.spyOn(db, "transaction").mockImplementation(async (action, config) => {
const result = await transaction(action, config);
if (!interrupted && result && typeof result === "object" && "status" in result && result.status === "uploaded") {
interrupted = true; throw new Error("uploaded checkpoint reply lost");
}
return result;
});
try {
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("uploaded checkpoint reply lost");
expect(interrupted).toBe(true);
} finally { fault.mockRestore(); }
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 transaction = db.transaction.bind(db), get = vi.spyOn(storage, "getObject"); let expired = false;
const fault = vi.spyOn(db, "transaction").mockImplementation(async (action, config) => {
const result = await transaction(action, config);
if (!expired && result && typeof result === "object" && "status" in result && result.status === "uploaded") {
expired = true;
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;
});
try {
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("remote_workspace_migration_publication_changed");
expect(expired).toBe(true); expect(get).not.toHaveBeenCalled();
} finally { fault.mockRestore(); }
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));
expect(await migrateRemoteWorkspace({ db, storage, ...f, apply: true })).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("captures 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("imports on Windows without downloading the uploaded archive", async () => {
const f = await seed(), get = vi.spyOn(storage, "getObject"), platform = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
try {
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).resolves.toMatchObject({ applied: true });
expect(get).not.toHaveBeenCalled();
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)?.snapshotId).toBeTruthy();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("original saved work\n");
} finally { platform.mockRestore(); }
});
it.each([false, true])("removes capture scratch and preserves read-only source 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 put = storage.putFile.bind(storage), get = vi.spyOn(storage, "getObject");
let uploads = 0;
storage.putFile = async input => {
const result = await put(input); uploads++;
if (sourceChanged) await fs.writeFile(path.join(f.cwd, "saved.txt"), "new local work\n");
return result;
};
try {
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).resolves.toMatchObject({ applied: true });
expect(uploads).toBe(1); expect(get).not.toHaveBeenCalled();
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)?.snapshotId).toBeTruthy();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe(sourceChanged ? "new local work\n" : "original saved work\n");
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("reuses the saved upload after local source edits while preserving the edited original", 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");
const capture = vi.spyOn(workspaceArchive, "captureRemoteWorkspaceDirectory"), get = vi.spyOn(storage, "getObject");
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).resolves.toMatchObject({ applied: true });
expect(capture).not.toHaveBeenCalled(); expect(get).not.toHaveBeenCalled();
const authority = readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)!;
expect(authority.snapshotId).toBeTruthy();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("new local edits\n");
const ref = (await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! }))!;
const archivePath = path.join(root, "saved-before-edit.pcws"), targetDir = path.join(root, "saved-before-edit");
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");
});
async function nestedGitWrapperFixture(relative = ".review/repository") {
const original = await seed();
vi.stubEnv("PAPERCLIP_HOME", await fs.realpath(root));
const cwd = path.join(resolvePaperclipInstanceRoot(), "isolated-workspaces", original.companyId, original.issueId);
await fs.mkdir(path.dirname(cwd), { recursive: true }); await fs.rename(original.cwd, cwd);
await db.update(executionWorkspaces).set({ cwd, providerRef: cwd }).where(eq(executionWorkspaces.id, original.workspaceId));
const f = { ...original, cwd }, nested = path.join(cwd, relative);
await fs.mkdir(nested, { recursive: true }); await fs.writeFile(path.join(nested, "tracked.txt"), "committed\n");
await git(nested, "init", "-q", "-b", "feature"); await git(nested, "add", "."); await git(nested, "commit", "-qm", "nested work");
const head = await git(nested, "rev-parse", "HEAD");
await fs.writeFile(path.join(nested, "tracked.txt"), "staged\n"); await git(nested, "add", "tracked.txt");
await fs.writeFile(path.join(nested, "tracked.txt"), "unstaged\n");
return { f, nested, head };
}
it.each(["fresh", "resume", "nested-runtime", "host-repository"])("imports a nested Git wrapper and retains its pending journal on resume (%s)", async mode => {
const relative = mode === "nested-runtime" ? ".review/.paperclip-runtime/repository" : ".review/repository";
const { f, head } = await nestedGitWrapperFixture(relative);
let hostHead: string | undefined;
if (mode === "host-repository") {
await git(root, "init", "-q", "-b", "host"); await fs.writeFile(path.join(root, "host-only.txt"), "host repository\n");
await git(root, "add", "host-only.txt"); await git(root, "commit", "-qm", "host work"); hostHead = await git(root, "rev-parse", "HEAD");
}
expect(await inventoryRemoteWorkspaceMigration(db, f)).toMatchObject({ eligible: true });
let operationId: string | undefined, snapshotId: string | undefined;
if (mode === "resume") {
const capture = vi.spyOn(workspaceArchive, "captureRemoteWorkspaceDirectory")
.mockRejectedValueOnce(new Error("remote_workspace_archive_online_source_git_requires_shared_fence"));
try {
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true }))
.rejects.toThrow("remote_workspace_archive_online_source_git_requires_shared_fence");
} finally { capture.mockRestore(); }
const [operation] = await db.select().from(workspaceOperations), [snapshot] = await db.select().from(executionWorkspaceSnapshots);
expect(operation).toMatchObject({ status: "running", metadata: { source: { kind: "empty" }, sourceIssueBinding: f.workspaceId,
nestedWorkspaceScope: { version: 1, state: "fixed", children: [] } } });
expect(snapshot).toMatchObject({ status: "pending", sourceOperationId: operation!.id, generation: 1, epoch: 1,
sourceRunId: null, parentSnapshotId: null, byteSize: null, sha256: null });
operationId = operation!.id; snapshotId = snapshot!.id;
}
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).resolves.toMatchObject({ applied: true });
const authority = readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)!;
const operations = await db.select().from(workspaceOperations), snapshots = await db.select().from(executionWorkspaceSnapshots);
expect(operations).toHaveLength(1); expect(snapshots).toHaveLength(1);
expect(operations[0]).toMatchObject({ ...(operationId ? { id: operationId } : {}), status: "succeeded" });
expect(snapshots[0]).toMatchObject({ ...(snapshotId ? { id: snapshotId } : {}), status: "committed", sourceOperationId: operations[0]!.id });
expect(authority.snapshotId).toBe(snapshots[0]!.id);
const ref = await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! });
const archivePath = path.join(root, "nested.pcws"), targetDir = path.join(root, "restored-nested");
await loadWorkspaceSnapshotArchive({ storage, reference: ref!, targetPath: archivePath });
await fs.rm(f.cwd, { recursive: true });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref!.manifestDigest });
const restored = path.join(targetDir, relative);
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("original saved work\n");
expect(await git(restored, "rev-parse", "HEAD")).toBe(head);
expect(await git(restored, "show", ":tracked.txt")).toBe("staged");
expect(await fs.readFile(path.join(restored, "tracked.txt"), "utf8")).toBe("unstaged\n");
if (hostHead) {
expect(await git(root, "rev-parse", "HEAD")).toBe(hostHead);
expect(await fs.lstat(path.join(targetDir, ".git")).catch(() => null)).toBeNull();
expect(await fs.lstat(path.join(targetDir, "host-only.txt")).catch(() => null)).toBeNull();
}
});
it.each(["linked", "alternate", "alternate-linked-wrapper"])("fences a nested Git wrapper's shared %s source before journal creation", async mode => {
const { f, nested } = await nestedGitWrapperFixture();
const [sourceIssue] = await db.insert(issues).values({ companyId: f.companyId, title: "Shared Git source" }).returning();
const source = path.join(resolvePaperclipInstanceRoot(), "isolated-repository-sources", f.companyId, sourceIssue!.id, "checkout");
await fs.mkdir(source, { recursive: true }); await fs.writeFile(path.join(source, "source.txt"), "shared source\n");
await git(source, "init", "-q", "-b", "main"); await git(source, "add", "."); await git(source, "commit", "-qm", "shared source");
const sourceWorkspaceId = randomUUID();
await db.insert(executionWorkspaces).values({ id: sourceWorkspaceId, companyId: f.companyId, sourceIssueId: sourceIssue!.id,
mode: "isolated_workspace", strategyType: "git_worktree", providerType: "git_worktree", name: "Shared source", cwd: source, providerRef: source });
await fs.rm(nested, { recursive: true });
if (mode === "linked") await git(source, "worktree", "add", "-b", "nested", nested);
else { await git(root, "clone", "--shared", source, nested); await git(nested, "remote", "remove", "origin"); }
const workspaceIds = [f.workspaceId, sourceWorkspaceId], issueIds = [f.issueId, sourceIssue!.id];
let writerIssueId = sourceIssue!.id;
if (mode === "alternate-linked-wrapper") {
const [writer] = await db.insert(issues).values({ companyId: f.companyId, title: "Linked source writer" }).returning();
writerIssueId = writer!.id;
const writerWorkspaceId = randomUUID(), writerCwd = path.join(resolvePaperclipInstanceRoot(), "isolated-workspaces", f.companyId, writerIssueId);
await fs.mkdir(writerCwd, { recursive: true }); await git(source, "worktree", "add", "--detach", path.join(writerCwd, "nested"));
await db.insert(executionWorkspaces).values({ id: writerWorkspaceId, companyId: f.companyId, sourceIssueId: writerIssueId,
name: "Linked writer wrapper", mode: "isolated_workspace", strategyType: "project_primary", providerType: "local_fs", cwd: writerCwd, providerRef: writerCwd });
workspaceIds.push(writerWorkspaceId); issueIds.push(writerIssueId);
}
const scope = await inspectOnlineWorkspaceSource(db, await workspace(f.workspaceId));
expect(scope).toMatchObject({ kind: "git", root: f.cwd });
expect(scope!.workspaceIds).toEqual(workspaceIds.sort());
expect(scope!.issueIds).toEqual(issueIds.sort());
let acquire!: () => void, release!: () => void;
const acquired = new Promise<void>(resolve => { acquire = resolve; });
const released = new Promise<void>(resolve => { release = resolve; });
const holder = withWorkspaceSourceLocks(db, { companyId: f.companyId, issueIds: [writerIssueId] }, async () => {
acquire(); await released;
});
await acquired;
try {
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("workspace_source_busy");
expect(await db.select().from(workspaceOperations)).toHaveLength(0);
const [other] = await db.insert(issues).values({ companyId: f.companyId, title: "Unrelated workspace" }).returning();
const otherId = randomUUID(), otherCwd = path.join(resolvePaperclipInstanceRoot(), "isolated-workspaces", f.companyId, other!.id);
await fs.mkdir(otherCwd, { recursive: true }); await fs.writeFile(path.join(otherCwd, "other.txt"), "unrelated work\n");
await db.insert(executionWorkspaces).values({ id: otherId, companyId: f.companyId, sourceIssueId: other!.id,
name: "Unrelated workspace", mode: "isolated_workspace", strategyType: "project_primary", providerType: "local_fs", cwd: otherCwd, providerRef: otherCwd });
await db.update(issues).set({ executionWorkspaceId: otherId }).where(eq(issues.id, other!.id));
await expect(migrateRemoteWorkspace({ db, storage, companyId: f.companyId, workspaceId: otherId, apply: true }))
.resolves.toMatchObject({ applied: true });
}
finally { release(); await holder; }
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).resolves.toMatchObject({ applied: true });
expect(await fs.readFile(path.join(nested, "source.txt"), "utf8")).toBe("shared source\n");
});
it.each(["foreign-alias", "unowned-alternate"])("keeps a nested Git wrapper blocked by %s", async mode => {
const { f, nested } = await nestedGitWrapperFixture();
if (mode === "foreign-alias") {
const foreignCompanyId = randomUUID(), foreignIssueId = randomUUID(), foreignCwd = path.join(root, "foreign-worktree");
await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign company", issuePrefix: "OTHER" });
await db.insert(issues).values({ id: foreignIssueId, companyId: foreignCompanyId, title: "Foreign task" });
await git(nested, "worktree", "add", "-b", "foreign", foreignCwd);
await db.insert(executionWorkspaces).values({ companyId: foreignCompanyId, sourceIssueId: foreignIssueId,
name: "Foreign alias", mode: "isolated_workspace", strategyType: "git_worktree", providerType: "git_worktree", cwd: foreignCwd, providerRef: foreignCwd });
} else {
const objects = path.join(root, "unowned-objects"); await fs.mkdir(objects);
await fs.writeFile(path.join(nested, ".git", "objects", "info", "alternates"), objects + "\n");
}
expect(await inventoryRemoteWorkspaceMigration(db, f)).toMatchObject({ eligible: false, reasons: ["online_workspace_source_scope_unproven"] });
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("online_workspace_source_scope_unproven");
expect(await db.select().from(workspaceOperations)).toHaveLength(0);
expect(await db.select().from(executionWorkspaceSnapshots)).toHaveLength(0);
expect(await fs.readFile(path.join(nested, "tracked.txt"), "utf8")).toBe("unstaged\n");
});
it.each([false, true])("fences another ordinary wrapper's nested linked Git alias (foreign=%s)", async foreign => {
const { f, nested } = await nestedGitWrapperFixture();
const companyId = foreign ? randomUUID() : f.companyId, issueId = randomUUID(), workspaceId = randomUUID();
if (foreign) await db.insert(companies).values({ id: companyId, name: "Foreign company", issuePrefix: "OTHER" });
await db.insert(issues).values({ id: issueId, companyId, title: "Nested linked alias" });
const cwd = path.join(resolvePaperclipInstanceRoot(), "isolated-workspaces", companyId, issueId);
await fs.mkdir(cwd, { recursive: true });
await git(nested, "worktree", "add", "-b", "hidden-alias", path.join(cwd, "nested"));
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, sourceIssueId: issueId,
name: "Ordinary wrapper alias", mode: "isolated_workspace", strategyType: "project_primary", providerType: "local_fs", cwd, providerRef: cwd });
if (foreign) {
expect(await inventoryRemoteWorkspaceMigration(db, f)).toMatchObject({ eligible: false, reasons: ["online_workspace_source_scope_unproven"] });
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("online_workspace_source_scope_unproven");
} else {
const scope = await inspectOnlineWorkspaceSource(db, await workspace(f.workspaceId));
expect(scope!.workspaceIds).toEqual([f.workspaceId, workspaceId].sort());
expect(scope!.issueIds).toEqual([f.issueId, issueId].sort());
let acquire!: () => void, release!: () => void;
const acquired = new Promise<void>(resolve => { acquire = resolve; });
const released = new Promise<void>(resolve => { release = resolve; });
const holder = withWorkspaceSourceLocks(db, { companyId, issueIds: [issueId] }, async () => { acquire(); await released; });
await acquired;
try {
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("workspace_source_busy");
} finally { release(); await holder; }
}
expect(await db.select().from(workspaceOperations)).toHaveLength(0);
expect(await db.select().from(executionWorkspaceSnapshots)).toHaveLength(0);
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
if (!foreign) await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).resolves.toMatchObject({ applied: true });
});
async function importedLinkedFixture() {
const { f, nested } = await nestedGitWrapperFixture(), issueId = randomUUID(), workspaceId = randomUUID();
await db.insert(issues).values({ id: issueId, companyId: f.companyId, title: "Imported linked workspace" });
const cwd = path.join(resolvePaperclipInstanceRoot(), "isolated-workspaces", f.companyId, issueId);
await git(nested, "worktree", "add", "-b", "imported-linked", cwd);
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId: f.companyId, sourceIssueId: issueId,
name: "Imported linked workspace", mode: "isolated_workspace", strategyType: "git_worktree", providerType: "git_worktree", cwd, providerRef: cwd });
await db.update(issues).set({ executionWorkspaceId: workspaceId }).where(eq(issues.id, issueId));
await migrateRemoteWorkspace({ db, storage, companyId: f.companyId, workspaceId, apply: true });
const [operation] = await db.select().from(workspaceOperations).where(eq(workspaceOperations.executionWorkspaceId, workspaceId));
const imported = await workspace(workspaceId), authority = readRemoteWorkspaceAuthority(imported.metadata)!;
return { f, nested, issueId, workspaceId, cwd, operation: operation!, imported, authority };
}
it.each(["committed", "superseded"] as const)("recognizes a canonically imported linked Git source (%s)", async status => {
const { f, nested, issueId, workspaceId, cwd, operation, imported, authority } = await importedLinkedFixture();
if (status === "superseded") await db.update(executionWorkspaceSnapshots).set({ status }).where(eq(executionWorkspaceSnapshots.id, authority.snapshotId!));
const scope = await inspectOnlineWorkspaceSource(db, await workspace(f.workspaceId));
expect(scope).toMatchObject({ kind: "git", workspaceIds: [f.workspaceId],
importedChildren: [expect.objectContaining({ workspaceId, operationId: operation.id, sourcePath: cwd })] });
expect(scope!.physicalRoots).toContain(cwd); expect(scope!.issueIds).toContain(issueId);
let acquire!: () => void, release!: () => void;
const acquired = new Promise<void>(resolve => { acquire = resolve; }), released = new Promise<void>(resolve => { release = resolve; });
const holder = withWorkspaceSourceLocks(db, { companyId: f.companyId, issueIds: [issueId] }, async () => { acquire(); await released; });
await acquired;
try { await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("workspace_source_busy"); }
finally { release(); await holder; }
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).resolves.toMatchObject({ applied: true });
expect(await workspace(workspaceId)).toEqual(imported);
expect(await fs.readFile(path.join(cwd, "tracked.txt"), "utf8")).toBe("committed\n");
expect(await fs.readFile(path.join(nested, "tracked.txt"), "utf8")).toBe("unstaged\n");
});
it.each(["journal", "snapshot", "manifest", "unfinished"] as const)("refuses an imported linked Git source with changed %s provenance", async change => {
const { f, operation, authority } = await importedLinkedFixture();
if (change === "journal") await db.update(workspaceOperations).set({ status: "failed" }).where(eq(workspaceOperations.id, operation.id));
if (change === "snapshot") await db.update(executionWorkspaceSnapshots).set({ status: "uploaded" }).where(eq(executionWorkspaceSnapshots.id, authority.snapshotId!));
if (change === "manifest") await db.update(workspaceOperations).set({ metadata: { ...operation.metadata, manifestDigest: "a".repeat(64) } }).where(eq(workspaceOperations.id, operation.id));
if (change === "unfinished") await db.update(workspaceOperations).set({ finishedAt: null }).where(eq(workspaceOperations.id, operation.id));
expect(await inspectOnlineWorkspaceSource(db, await workspace(f.workspaceId))).toBeNull();
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("online_workspace_source_scope_unproven");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
});
it("keeps a foreign local writer blocked after a linked Git source is imported", async () => {
const { f, cwd } = await importedLinkedFixture(), companyId = randomUUID(), issueId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Foreign owner", issuePrefix: "FOREIGN" });
await db.insert(issues).values({ id: issueId, companyId, title: "Foreign source alias" });
await db.insert(executionWorkspaces).values({ companyId, sourceIssueId: issueId, name: "Foreign source alias", mode: "isolated_workspace",
strategyType: "git_worktree", providerType: "git_worktree", cwd, providerRef: cwd });
expect(await inspectOnlineWorkspaceSource(db, await workspace(f.workspaceId))).toBeNull();
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("online_workspace_source_scope_unproven");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
});
it.each(["unregistered", "backlink", "pointer", "symlink-backlink"])("refuses a nested Git wrapper with an unproven linked %s", async mode => {
const { f, nested } = await nestedGitWrapperFixture(), issueId = randomUUID();
await db.insert(issues).values({ id: issueId, companyId: f.companyId, title: "Linked wrapper" });
const cwd = path.join(resolvePaperclipInstanceRoot(), "isolated-workspaces", f.companyId, issueId), alias = path.join(cwd, "nested");
await fs.mkdir(cwd, { recursive: true }); await git(nested, "worktree", "add", "-b", "linked-proof", alias);
if (mode !== "unregistered") await db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: issueId,
name: "Linked wrapper", mode: "isolated_workspace", strategyType: "project_primary", providerType: "local_fs", cwd, providerRef: cwd });
const admin = await git(alias, "rev-parse", "--absolute-git-dir"), backlink = path.join(admin, "gitdir");
if (mode === "backlink") await fs.writeFile(backlink, path.join(alias, "wrong-marker") + "\n");
if (mode === "pointer") await fs.writeFile(path.join(alias, ".git"), `gitdir: ${path.join(admin, "wrong")}\n`);
if (mode === "symlink-backlink") { await fs.rename(backlink, backlink + ".real"); await fs.symlink(backlink + ".real", backlink); }
expect(await inventoryRemoteWorkspaceMigration(db, f)).toMatchObject({ eligible: false, reasons: ["online_workspace_source_scope_unproven"] });
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("online_workspace_source_scope_unproven");
expect(await db.select().from(workspaceOperations)).toHaveLength(0);
expect(await db.select().from(executionWorkspaceSnapshots)).toHaveLength(0);
});
it("revalidates the linked Git listing before publishing a nested wrapper", async () => {
const { f, nested } = await nestedGitWrapperFixture(), issueId = randomUUID();
await db.insert(issues).values({ id: issueId, companyId: f.companyId, title: "Linked wrapper" });
const cwd = path.join(resolvePaperclipInstanceRoot(), "isolated-workspaces", f.companyId, issueId);
await fs.mkdir(cwd, { recursive: true }); await git(nested, "worktree", "add", "--detach", path.join(cwd, "first"));
await db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: issueId, name: "Linked wrapper",
mode: "isolated_workspace", strategyType: "project_primary", providerType: "local_fs", cwd, providerRef: cwd });
const before = await inspectOnlineWorkspaceSource(db, await workspace(f.workspaceId)), putFile = storage.putFile.bind(storage);
vi.spyOn(storage, "putFile").mockImplementationOnce(async input => {
const result = await putFile(input);
await git(nested, "worktree", "add", "--detach", path.join(cwd, "second"));
return result;
});
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("online_workspace_source_scope_changed");
const after = await inspectOnlineWorkspaceSource(db, await workspace(f.workspaceId));
expect(after!.workspaceIds).toEqual(before!.workspaceIds);
expect(after!.gitSourceFingerprints).not.toEqual(before!.gitSourceFingerprints);
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
});
it("rechecks a nested Git wrapper's alternate metadata before publication", async () => {
const { f, nested } = await nestedGitWrapperFixture(), objects = path.join(f.cwd, "alternate-objects");
await fs.mkdir(objects);
const before = await inspectOnlineWorkspaceSource(db, await workspace(f.workspaceId));
expect(before).toMatchObject({ kind: "git", root: f.cwd });
const putFile = storage.putFile.bind(storage);
vi.spyOn(storage, "putFile").mockImplementationOnce(async input => {
const result = await putFile(input);
await fs.writeFile(path.join(nested, ".git", "objects", "info", "alternates"), objects + "\n");
return result;
});
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("online_workspace_source_scope_changed");
const after = await inspectOnlineWorkspaceSource(db, await workspace(f.workspaceId));
expect(after).toMatchObject({ kind: "git", root: f.cwd });
expect(after!.gitSourceFingerprints).not.toEqual(before!.gitSourceFingerprints);
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await fs.readFile(path.join(nested, "tracked.txt"), "utf8")).toBe("unstaged\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 upload", async () => {
const f = await seed(), put = storage.putFile.bind(storage); let uploads = 0;
storage.putFile = async input => {
const saved = await put(input);
if (++uploads === 1) 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 saved;
};
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("workspace_runtime_service_unsettled");
expect(uploads).toBe(1);
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 during upload", async () => {
const f = await seed(), put = storage.putFile.bind(storage); let uploads = 0;
storage.putFile = async input => {
const saved = await put(input);
if (++uploads === 1) await db.insert(workspaceOperations).values({ companyId: f.companyId, executionWorkspaceId: f.workspaceId,
phase: "workspace_command", status: "running" });
return saved;
};
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("workspace_operation_unresolved");
expect(uploads).toBe(1);
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
});
it("preserves local edits made during upload and activates the saved capture without rereading", async () => {
const f = await seed(), put = storage.putFile.bind(storage), get = vi.spyOn(storage, "getObject");
const capture = vi.spyOn(workspaceArchive, "captureRemoteWorkspaceDirectory"); let uploads = 0;
storage.putFile = async input => { const saved = await put(input); uploads++;
await fs.writeFile(path.join(f.cwd, "saved.txt"), "new local work\n"); return saved; };
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).resolves.toMatchObject({ applied: true });
expect(uploads).toBe(1); expect(get).not.toHaveBeenCalled(); expect(capture).toHaveBeenCalledTimes(1);
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)?.snapshotId).toBeTruthy();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("new local work\n");
});
});