fix(workspaces): migrate overlapping workspace trees offline

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-03 13:39:16 -06:00
1 parent 2035118526
commit 8a0cb107ba
9 files changed
+339 -16

No files matched your search

@@ -8,6 +8,7 @@ import { skillInstallationMaintenance } from "../services/skill-installation-mai
import { skillPackageMaintenance } from "../services/skill-package-maintenance.js";
import { hasUnsettledLocalInstructionCopies } from "../services/agent-instruction-working-copies.js";
import { providerTraceStore } from "../services/provider-trace-store.js";
import { migrateWorkspaceTrees } from "../services/remote-workspace-migration-batch.js";
/** Runs only in the maintenance process after Cloud has confirmed all legacy
* deployments stopped. Each owner retains its own migration/publication rules;
@@ -23,10 +24,15 @@ export async function migrateEphemeralServerData(db: Db, input: {
if (await hasUnsettledLocalInstructionCopies(db)) throw new Error("local_instruction_copies_require_settlement");
const before = await readEphemeralServerDataEligibility(db);
const unsupported = before.blockers.filter(blocker => ![
"agent_files_require_migration", "run_logs_require_migration", "provider_traces_require_migration", "skills_require_migration",
"agent_files_require_migration", "run_logs_require_migration", "provider_traces_require_migration", "skills_require_migration", "workspaces_require_migration",
].includes(blocker));
if (unsupported.length) throw new Error(unsupported[0]);
phase("workspaces");
const storage = getStorageService();
if (storage.provider !== "s3") throw new Error("shared_object_storage_required");
counts.workspaces = await migrateWorkspaceTrees(db, { storage, signal, phase });
phase("agent_files");
let after: string | undefined;
do {
@@ -77,8 +83,6 @@ export async function migrateEphemeralServerData(db: Db, input: {
}
phase("verify_agent_objects");
const storage = getStorageService();
if (storage.provider !== "s3") throw new Error("shared_object_storage_required");
counts.verifiedAgentObjects = await verifyAgentFileObjects(db, storage, signal);
phase("verify_skill_versions");
after = undefined;
@@ -0,0 +1,31 @@
import { and, asc, desc, isNotNull, sql } from "drizzle-orm";
import { executionWorkspaces, type Db } from "@paperclipai/db";
import type { StorageService } from "../storage/types.js";
import { offlineWorkspaceMigrationBlockers } from "./remote-workspace-migration-tree.js";
import { migrateRemoteWorkspace } from "./remote-workspace-migration.js";
/** The Cloud maintenance process owns the stop-all-deployments precondition.
* Imported records leave the pending set, so a restart resumes the same owner
* journals without moving the task binding or importing over remote authority. */
export async function migrateWorkspaceTrees(db: Db, input: {
storage: StorageService; signal: AbortSignal; phase: (name: string) => void;
}) {
const blockers = await offlineWorkspaceMigrationBlockers(db);
if (blockers.length) throw new Error(blockers[0]);
let migrated = 0;
for (;;) {
input.signal.throwIfAborted();
const rows = await db.select({ companyId: executionWorkspaces.companyId, workspaceId: executionWorkspaces.id })
.from(executionWorkspaces).where(and(isNotNull(executionWorkspaces.cwd),
sql`${executionWorkspaces.providerType} <> 'plugin'`,
sql`coalesce(${executionWorkspaces.metadata}->'remoteWorkspace'->>'schema', '') <> 'paperclip.remote-workspace.v1'`,
)).orderBy(desc(sql`length(${executionWorkspaces.cwd})`), asc(executionWorkspaces.id)).limit(25);
if (!rows.length) return migrated;
for (const workspace of rows) {
input.signal.throwIfAborted();
input.phase(`workspace:${workspace.workspaceId}`);
await migrateRemoteWorkspace({ db, storage: input.storage, ...workspace, apply: true, writersStopped: true, signal: input.signal });
migrated++;
}
}
}
@@ -0,0 +1,122 @@
import fs from "node:fs/promises";
import path from "node:path";
import { and, eq, inArray, ne, sql } from "drizzle-orm";
import { executionWorkspaces, executionWorkspaceSnapshots, heartbeatRuns, workspaceOperations, type Db } from "@paperclipai/db";
import type { StorageService } from "../storage/types.js";
import { captureRemoteWorkspaceDirectory, restoreRemoteWorkspaceArchive } from "./remote-workspace-snapshot-archive.js";
import { getWorkspaceSnapshot, loadWorkspaceSnapshotArchive } from "./remote-workspace-snapshot-storage.js";
type ReadDb = Pick<Db, "select">;
type Workspace = typeof executionWorkspaces.$inferSelect;
export type ImportedWorkspaceChild = {
workspaceId: string; snapshotId: string; operationId: string; relativePath: string; manifestDigest: string;
};
const overlaps = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
const rootOf = (workspace: Pick<Workspace, "cwd" | "providerRef">) => workspace.cwd ?? workspace.providerRef;
export async function offlineWorkspaceMigrationBlockers(db: ReadDb) {
if (process.env.PAPERCLIP_EPHEMERAL_MIGRATION !== "true") return ["offline_maintenance_required"];
const [active] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns)
.where(inArray(heartbeatRuns.status, ["running", "queued"])).limit(1);
return active ? ["active_execution_requires_drain"] : [];
}
/** Only the maintenance entry point can acknowledge that every old deployment
* has stopped. A task-level migration hold cannot fence other path aliases. */
export async function workspaceMigrationLayoutBlockers(db: ReadDb, workspace: Workspace, writersStopped = false) {
if (writersStopped) return offlineWorkspaceMigrationBlockers(db);
const cwd = rootOf(workspace);
if (!cwd) return [];
const local = await db.select({ id: executionWorkspaces.id, cwd: executionWorkspaces.cwd,
providerRef: executionWorkspaces.providerRef }).from(executionWorkspaces).where(and(
eq(executionWorkspaces.companyId, workspace.companyId), ne(executionWorkspaces.id, workspace.id),
inArray(executionWorkspaces.providerType, ["local_fs", "git_worktree"]),
));
if (local.some(row => rootOf(row) && overlaps(path.resolve(cwd), path.resolve(rootOf(row)!)))) {
return ["overlapping_workspace_requires_maintenance"];
}
const migrated = await db.select({ cwd: workspaceOperations.cwd }).from(workspaceOperations).where(and(
eq(workspaceOperations.companyId, workspace.companyId), eq(workspaceOperations.phase, "remote_workspace_migration"),
eq(workspaceOperations.status, "succeeded"), ne(workspaceOperations.executionWorkspaceId, workspace.id),
));
return migrated.some(row => row.cwd && path.resolve(row.cwd).startsWith(`${path.resolve(cwd)}/.paperclip/worktrees/`))
? ["overlapping_workspace_requires_maintenance"] : [];
}
/** Runtime-created nested task folders can be omitted only after an exact
* committed import covers their current bytes. Ordinary user folders stay in
* the parent archive. The snapshot owner retains the resulting dependencies. */
export async function prepareNestedWorkspaceImports(input: {
db: Db; storage: StorageService; workspace: Workspace; scratch: string; signal?: AbortSignal;
}): Promise<ImportedWorkspaceChild[]> {
const root = rootOf(input.workspace);
if (!root) return [];
const rows = await input.db.select({ operation: workspaceOperations, snapshot: executionWorkspaceSnapshots })
.from(workspaceOperations).innerJoin(executionWorkspaceSnapshots, and(
eq(executionWorkspaceSnapshots.companyId, workspaceOperations.companyId),
eq(executionWorkspaceSnapshots.executionWorkspaceId, workspaceOperations.executionWorkspaceId),
eq(executionWorkspaceSnapshots.sourceOperationId, workspaceOperations.id),
)).where(and(eq(workspaceOperations.companyId, input.workspace.companyId),
ne(workspaceOperations.executionWorkspaceId, input.workspace.id), eq(workspaceOperations.phase, "remote_workspace_migration"),
eq(workspaceOperations.status, "succeeded"), inArray(executionWorkspaceSnapshots.status, ["committed", "superseded"]),
sql`${workspaceOperations.metadata}->>'snapshotId' = ${executionWorkspaceSnapshots.id}::text`,
));
const candidates = rows.filter(row => row.operation.cwd && path.resolve(row.operation.cwd).startsWith(`${path.resolve(root)}/.paperclip/worktrees/`))
.sort((a, b) => a.operation.cwd!.length - b.operation.cwd!.length || a.operation.id.localeCompare(b.operation.id));
const covered: ImportedWorkspaceChild[] = [];
for (const { operation, snapshot } of candidates) {
input.signal?.throwIfAborted();
const relativePath = path.relative(root, operation.cwd!).split(path.sep).join("/");
if (covered.some(item => relativePath === item.relativePath || relativePath.startsWith(`${item.relativePath}/`))) continue;
const entry = await fs.lstat(operation.cwd!).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return null; throw error; });
if (!entry) continue;
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("nested_workspace_source_changed");
await verifyCoveredWorkspace({ ...input, sourcePath: operation.cwd!, snapshot, operationId: operation.id,
expectedDigest: operation.metadata?.manifestDigest, depth: 0 });
covered.push({ workspaceId: snapshot.executionWorkspaceId, snapshotId: snapshot.id, operationId: operation.id,
relativePath, manifestDigest: snapshot.manifestDigest! });
}
return covered;
}
async function verifyCoveredWorkspace(input: {
db: Db; storage: StorageService; sourcePath: string; snapshot: typeof executionWorkspaceSnapshots.$inferSelect;
operationId: string; expectedDigest: unknown; scratch: string; signal?: AbortSignal; depth: number;
}) {
input.signal?.throwIfAborted();
if (input.depth > 32) throw new Error("nested_workspace_coverage_invalid");
const { snapshot } = input;
const reference = await getWorkspaceSnapshot(input.db, { companyId: snapshot.companyId,
workspaceId: snapshot.executionWorkspaceId, snapshotId: snapshot.id });
if (!reference || reference.manifestDigest !== input.expectedDigest || snapshot.sourceOperationId !== input.operationId)
throw new Error("nested_workspace_snapshot_changed");
const raw = snapshot.metadata?.importedWorkspaceChildren;
if (raw !== undefined && (!Array.isArray(raw) || raw.some(child => !child || typeof child !== "object"
|| typeof child.relativePath !== "string" || !child.relativePath.startsWith(".paperclip/worktrees/")
|| child.relativePath.split("/").some((part: string) => !part || part === "." || part === "..")
|| !/^[a-f0-9-]{36}$/.test(child.workspaceId) || !/^[a-f0-9-]{36}$/.test(child.snapshotId)
|| !/^[a-f0-9-]{36}$/.test(child.operationId) || !/^[a-f0-9]{64}$/.test(child.manifestDigest)))) {
throw new Error("nested_workspace_coverage_invalid");
}
const children = (raw ?? []) as ImportedWorkspaceChild[];
const area = await fs.mkdtemp(path.join(input.scratch, "covered-"));
for (const child of children) {
const [saved] = await input.db.select({ operation: workspaceOperations, snapshot: executionWorkspaceSnapshots })
.from(executionWorkspaceSnapshots).innerJoin(workspaceOperations, and(
eq(workspaceOperations.companyId, executionWorkspaceSnapshots.companyId), eq(workspaceOperations.id, executionWorkspaceSnapshots.sourceOperationId),
)).where(and(eq(executionWorkspaceSnapshots.companyId, snapshot.companyId), eq(executionWorkspaceSnapshots.id, child.snapshotId),
eq(executionWorkspaceSnapshots.executionWorkspaceId, child.workspaceId), eq(workspaceOperations.id, child.operationId),
eq(workspaceOperations.status, "succeeded"), eq(workspaceOperations.phase, "remote_workspace_migration"),
inArray(executionWorkspaceSnapshots.status, ["committed", "superseded"])));
const sourcePath = path.join(input.sourcePath, child.relativePath);
if (!saved || saved.operation.cwd !== sourcePath) throw new Error("nested_workspace_coverage_invalid");
await verifyCoveredWorkspace({ ...input, sourcePath, snapshot: saved.snapshot, operationId: child.operationId,
expectedDigest: child.manifestDigest, scratch: area, depth: input.depth + 1 });
}
const archivePath = path.join(area, "saved.pcws"), restored = path.join(area, "restored");
await loadWorkspaceSnapshotArchive({ storage: input.storage, reference, targetPath: archivePath, signal: input.signal });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir: restored, expectedManifestDigest: reference.manifestDigest, signal: input.signal });
const current = await captureRemoteWorkspaceDirectory({ cwd: input.sourcePath, archivePath: path.join(area, "current.pcws"),
exclude: children.map(child => child.relativePath), signal: input.signal });
if (current.manifestDigest !== reference.manifestDigest) throw new Error("nested_workspace_source_changed");
}
@@ -8,13 +8,14 @@ import { createServer, type Server } from "node:http";
import { pathToFileURL } from "node:url";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { activityLog, applyPendingMigrations, closeRegisteredClients, companies, createDb, executionWorkspaceSnapshots,
import { activityLog, agents, heartbeatRuns, applyPendingMigrations, closeRegisteredClients, companies, createDb, executionWorkspaceSnapshots,
executionWorkspaceRuntimeLeases, executionWorkspaces, issues, projects, projectWorkspaces, workspaceOperations, workspaceRuntimeServices } from "@paperclipai/db";
import { readRemoteWorkspaceAuthority } from "@paperclipai/shared";
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
import { createStorageService } from "../storage/service.js";
import type { StorageService } from "../storage/types.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
import { migrateWorkspaceTrees } from "./remote-workspace-migration-batch.js";
import { inventoryRemoteWorkspaceMigration, migrateRemoteWorkspace } from "./remote-workspace-migration.js";
import { getWorkspaceSnapshot, loadWorkspaceSnapshotArchive } from "./remote-workspace-snapshot-storage.js";
import { restoreRemoteWorkspaceArchive } from "./remote-workspace-snapshot-archive.js";
@@ -50,7 +51,7 @@ describe.skipIf(!support.supported)("remote workspace migration", () => {
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(issues); await db.delete(executionWorkspaces); await db.delete(projectWorkspaces); await db.delete(projects);
await db.delete(heartbeatRuns); await db.delete(agents); await db.delete(issues); 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() {
@@ -64,6 +65,108 @@ describe.skipIf(!support.supported)("remote workspace migration", () => {
return { companyId, workspaceId, issueId: issue!.id, cwd };
}
async function workspace(id: string) { return (await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, id)))[0]!; }
async function 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 %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 });
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("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(["changed", "corrupt", "late-change", "abort"])("keeps the parent local when nested coverage is unsafe (%s)", async mode => {
const { parent, child } = await nestedFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
await migrateRemoteWorkspace({ db, storage, ...child, apply: true, writersStopped: true });
const authority = readRemoteWorkspaceAuthority((await workspace(child.workspaceId)).metadata)!;
const reference = await getWorkspaceSnapshot(db, { ...child, snapshotId: authority.snapshotId! });
const controller = new AbortController();
if (mode === "changed") await fs.writeFile(path.join(child.cwd, "child.txt"), "changed child bytes");
if (mode === "corrupt") await storage.deleteObject(child.companyId, reference!.objectKey);
if (mode === "late-change" || mode === "abort") {
const put = storage.putFile.bind(storage); let once = true;
storage.putFile = async input => { const result = await put(input); if (once) { once = false;
if (mode === "abort") controller.abort(); else await fs.writeFile(path.join(child.cwd, "child.txt"), "changed during upload");
} return result; };
}
await expect(migrateRemoteWorkspace({ db, storage, ...parent, apply: true, writersStopped: true, signal: controller.signal })).rejects.toThrow();
expect(readRemoteWorkspaceAuthority((await workspace(parent.workspaceId)).metadata)).toBeNull();
expect(await fs.readFile(path.join(parent.cwd, "saved.txt"), "utf8")).toBe("original saved work\n");
if (mode === "abort") {
await migrateRemoteWorkspace({ db, storage, ...parent, apply: true, writersStopped: true });
expect(readRemoteWorkspaceAuthority((await workspace(parent.workspaceId)).metadata)?.snapshotId).toBeTruthy();
}
});
it("resumes an interrupted maintenance batch in leaf-first order without duplicating imports", async () => {
const { parent, child } = await nestedFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
const seen: string[] = [], controller = new AbortController();
await expect(migrateWorkspaceTrees(db, { storage, signal: controller.signal, phase: name => {
seen.push(name); if (name === `workspace:${parent.workspaceId}`) controller.abort();
} })).rejects.toThrow();
expect(seen).toEqual([`workspace:${child.workspaceId}`, `workspace:${parent.workspaceId}`]);
const childAuthority = readRemoteWorkspaceAuthority((await workspace(child.workspaceId)).metadata);
expect(await migrateWorkspaceTrees(db, { storage, signal: new AbortController().signal, phase: () => {} })).toBe(1);
expect(await migrateWorkspaceTrees(db, { storage, signal: new AbortController().signal, phase: () => {} })).toBe(0);
expect(readRemoteWorkspaceAuthority((await workspace(child.workspaceId)).metadata)).toEqual(childAuthority);
});
it("checks transitive nested coverage before omitting a child tree", async () => {
const { parent, child } = await nestedFixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_MIGRATION", "true");
const id = randomUUID(), cwd = path.join(child.cwd, ".paperclip", "worktrees", "grandchild");
await fs.mkdir(cwd, { recursive: true }); await fs.writeFile(path.join(cwd, "deep.txt"), "deep work");
const [issue] = await db.insert(issues).values({ companyId: parent.companyId, title: "Grandchild task" }).returning();
await db.insert(executionWorkspaces).values({ id, companyId: parent.companyId, sourceIssueId: issue!.id,
name: "Grandchild", mode: "isolated_workspace", strategyType: "project_primary", providerType: "local_fs", cwd });
await migrateRemoteWorkspace({ db, storage, companyId: parent.companyId, workspaceId: id, apply: true, writersStopped: true });
await migrateRemoteWorkspace({ db, storage, ...child, apply: true, writersStopped: true });
await fs.writeFile(path.join(cwd, "deep.txt"), "new deep work");
await expect(migrateRemoteWorkspace({ db, storage, ...parent, apply: true, writersStopped: true })).rejects.toThrow("nested_workspace_source_changed");
expect(readRemoteWorkspaceAuthority((await workspace(parent.workspaceId)).metadata)).toBeNull();
});
async function gitArchiveFixture(repoUrl = "https://example.invalid/repository.git") {
const f = await seed(), projectId = randomUUID(), projectWorkspaceId = randomUUID(), branchName = "saved-task";
vi.stubEnv("PAPERCLIP_HOME", root); vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default");
@@ -17,6 +17,7 @@ import { notifyRemoteWorkspaceSnapshots } from "./remote-workspace-snapshot-rete
import { materializeTaskDirectorySnapshot, removeTaskDirectoryTree } from "./isolated-task-directory-snapshot.js";
import { loadTaskDirectoryRestorePoint, mayBeIsolatedTaskDirectoryWorkspace, readTaskDirectoryRetention, validateTaskDirectoryRestorePointRef } from "./isolated-task-directory-retention.js";
import { inspectArchivedGitMigrationSource, materializeArchivedGitMigrationSource, type ArchivedGitMigrationSource } from "./remote-workspace-migration-git.js";
import { prepareNestedWorkspaceImports, workspaceMigrationLayoutBlockers } from "./remote-workspace-migration-tree.js";
const SCHEMA = "paperclip.remote-workspace-migration.v1";
type Workspace = typeof executionWorkspaces.$inferSelect;
@@ -38,7 +39,7 @@ const gitSourceProof = (source: ArchivedGitMigrationSource | null) => source ? {
commit: source.commit, sourcePath: source.sourcePath, cleanupOperationId: source.cleanupOperationId,
} : null;
async function inspect(db: ReadDb, input: { companyId: string; workspaceId: string; operationId?: string }) {
async function inspect(db: ReadDb, input: { companyId: string; workspaceId: string; operationId?: string; writersStopped?: boolean }) {
const [workspace] = await db.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.id, input.workspaceId)));
if (!workspace) throw new Error("remote_workspace_migration_workspace_missing");
const authority = readRemoteWorkspaceAuthority(workspace.metadata);
@@ -77,6 +78,7 @@ async function inspect(db: ReadDb, input: { companyId: string; workspaceId: stri
? "restore_point_expired" : "archived_git_source_unavailable"); }
}
if (!retainedDirectory && !archivedGitSource && (!entry?.isDirectory() || entry.isSymbolicLink())) reasons.push("local_source_unsafe_or_missing");
if (entry?.isDirectory() && !entry.isSymbolicLink()) reasons.push(...await workspaceMigrationLayoutBlockers(db, workspace, input.writersStopped));
}
const [issue] = workspace.sourceIssueId ? await db.select().from(issues).where(and(eq(issues.companyId, workspace.companyId), eq(issues.id, workspace.sourceIssueId))) : [];
// Historical records still own their saved files after the task selects a
@@ -142,7 +144,7 @@ async function inspect(db: ReadDb, input: { companyId: string; workspaceId: stri
return { workspace, reasons: [...new Set(reasons)], authority, marker, source, issueBinding: issue?.executionWorkspaceId ?? null, retainedDirectory, archivedGitSource };
}
export async function inventoryRemoteWorkspaceMigration(db: Db, input: { companyId: string; workspaceId: string }) {
export async function inventoryRemoteWorkspaceMigration(db: Db, input: { companyId: string; workspaceId: string; writersStopped?: boolean }) {
const result = await inspect(db, input);
return { companyId: input.companyId, workspaceId: input.workspaceId, eligible: result.reasons.length === 0,
alreadyMigrated: Boolean(result.authority), reasons: result.reasons, localPath: result.workspace.cwd ?? result.workspace.providerRef,
@@ -153,6 +155,7 @@ export async function inventoryRemoteWorkspaceMigration(db: Db, input: { company
/** Administrative import. The CLI enforces external deployment prerequisites. */
export async function migrateRemoteWorkspace(input: {
db: Db; storage: StorageService; companyId: string; workspaceId: string; apply?: boolean; source?: RemoteWorkspaceSource;
writersStopped?: boolean; signal?: AbortSignal;
}) {
if (!input.apply) return { ...await inventoryRemoteWorkspaceMigration(input.db, input), applied: false };
return withExecutionWorkspaceLifecycleLock(input.db, input.workspaceId, async lock => {
@@ -198,22 +201,32 @@ export async function migrateRemoteWorkspace(input: {
operationId: operation.id, generation: 1, epoch: 1 });
const scratch = await fs.mkdtemp(path.join(os.tmpdir(), "pc-workspace-migration-"));
try {
input.signal?.throwIfAborted();
const retained = reserved.retainedDirectory?.restorePoint;
const sourceRoot = retained ? await materializeTaskDirectorySnapshot({
payload: await loadTaskDirectoryRestorePoint({ storage: input.storage, workspace: reserved.workspace, ref: retained }),
target: path.join(scratch, "retained-source"), expectedDigest: retained.digest,
}) : reserved.archivedGitSource ? await materializeArchivedGitMigrationSource({ db: input.db, storage: input.storage,
workspace: reserved.workspace, source: reserved.archivedGitSource, scratch }) : reserved.workspace.cwd ?? reserved.workspace.providerRef!;
const nested = input.writersStopped && !retained && !reserved.archivedGitSource
? await prepareNestedWorkspaceImports({ ...input, workspace: reserved.workspace, scratch }) : [];
const exclude = nested.map(child => child.relativePath);
const archivePath = path.join(scratch, "workspace.pcws");
const captured = await captureRemoteWorkspaceDirectory({ cwd: sourceRoot, archivePath });
const captured = await captureRemoteWorkspaceDirectory({ cwd: sourceRoot, archivePath, exclude, signal: input.signal });
const ref = await uploadWorkspaceSnapshot({ db: input.db, storage: input.storage, snapshotId: snapshot.id,
companyId: input.companyId, workspaceId: input.workspaceId, archivePath, manifestDigest: captured.manifestDigest });
companyId: input.companyId, workspaceId: input.workspaceId, archivePath, manifestDigest: captured.manifestDigest, signal: input.signal });
const restoredArchive = path.join(scratch, "verified.pcws"), restored = path.join(scratch, "restored");
await loadWorkspaceSnapshotArchive({ storage: input.storage, reference: ref, targetPath: restoredArchive });
await restoreRemoteWorkspaceArchive({ archivePath: restoredArchive, targetDir: restored, expectedManifestDigest: ref.manifestDigest });
await loadWorkspaceSnapshotArchive({ storage: input.storage, reference: ref, targetPath: restoredArchive, signal: input.signal });
await restoreRemoteWorkspaceArchive({ archivePath: restoredArchive, targetDir: restored, expectedManifestDigest: ref.manifestDigest, signal: input.signal });
const currentArchive = path.join(scratch, "current.pcws");
const current = await captureRemoteWorkspaceDirectory({ cwd: sourceRoot, archivePath: currentArchive });
const current = await captureRemoteWorkspaceDirectory({ cwd: sourceRoot, archivePath: currentArchive, exclude, signal: input.signal });
if (current.manifestDigest !== captured.manifestDigest) throw new Error("remote_workspace_migration_source_changed");
if (nested.length) {
const verifyScratch = path.join(scratch, "verify-nested"); await fs.mkdir(verifyScratch);
const verified = await prepareNestedWorkspaceImports({ ...input, workspace: reserved.workspace, scratch: verifyScratch });
if (canonical(verified) !== canonical(nested)) throw new Error("nested_workspace_source_changed");
}
input.signal?.throwIfAborted();
await lock.execute(sql`select 1`);
const publication = await input.db.transaction(async tx => {
const [workspace] = await tx.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.id, input.workspaceId))).for("update");
@@ -231,11 +244,21 @@ export async function migrateRemoteWorkspace(input: {
const [uploaded] = await tx.select().from(executionWorkspaceSnapshots).where(and(eq(executionWorkspaceSnapshots.companyId, workspace.companyId),
eq(executionWorkspaceSnapshots.executionWorkspaceId, workspace.id), eq(executionWorkspaceSnapshots.id, ref.id))).for("update");
if (!uploaded || uploaded.status !== "uploaded" || uploaded.sourceOperationId !== operation.id) throw new Error("remote_workspace_migration_snapshot_changed");
for (const child of [...nested].sort((a, b) => a.snapshotId.localeCompare(b.snapshotId))) {
const [saved] = await tx.select().from(executionWorkspaceSnapshots).where(and(
eq(executionWorkspaceSnapshots.companyId, workspace.companyId), eq(executionWorkspaceSnapshots.id, child.snapshotId),
eq(executionWorkspaceSnapshots.executionWorkspaceId, child.workspaceId),
)).for("update");
if (!saved || !["committed", "superseded"].includes(saved.status) || saved.manifestDigest !== child.manifestDigest
|| saved.sourceOperationId !== child.operationId) throw new Error("nested_workspace_snapshot_changed");
}
const authority: RemoteWorkspaceAuthority = { schema: "paperclip.remote-workspace.v1", epoch: 1, snapshotId: ref.id,
source: reserved.source, location: null, writerRunId: null, dirty: false };
const metadata: Record<string, unknown> = { ...workspace.metadata, remoteWorkspace: authority }; delete metadata.remoteWorkspaceMigration;
await notifyRemoteWorkspaceSnapshots(tx, `workspace:${workspace.id}`);
await tx.update(executionWorkspaceSnapshots).set({ status: "committed", deleteAfter: null, updatedAt: new Date() }).where(eq(executionWorkspaceSnapshots.id, ref.id));
await tx.update(executionWorkspaceSnapshots).set({ status: "committed", deleteAfter: null,
metadata: { ...uploaded.metadata, ...(nested.length ? { importedWorkspaceChildren: nested } : {}) },
updatedAt: new Date() }).where(eq(executionWorkspaceSnapshots.id, ref.id));
await tx.update(executionWorkspaces).set({ providerType: "cloud_sandbox", providerRef: null, cwd: `/workspaces/${workspace.id}`, metadata,
updatedAt: new Date() }).where(eq(executionWorkspaces.id, workspace.id));
await tx.update(workspaceOperations).set({ status: "succeeded", finishedAt: new Date(), updatedAt: new Date(),
@@ -131,6 +131,24 @@ describe.skipIf(!support.supported)("remote workspace snapshot retention", () =>
for (const key of f.keys) expect((await storage.headObject(f.companyId, key)).exists).toBe(false);
expect(await owner.nextDeadlineAt()).toBeNull();
});
it("pins imported child bytes until the covering parent snapshot is deleted", async () => {
const child = await seed(), parentId = randomUUID(), workspaceId = randomUUID();
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId: child.companyId, name: "Imported parent", mode: "isolated_workspace", strategyType: "project_primary",
metadata: { remoteWorkspace: { snapshotId: parentId } } });
await db.insert(executionWorkspaceSnapshots).values({ id: parentId, companyId: child.companyId, executionWorkspaceId: workspaceId,
sourceOperationId: randomUUID(), generation: 1, epoch: 1, status: "committed",
metadata: { importedWorkspaceChildren: [{ workspaceId: child.workspaceId, snapshotId: child.row.id }] } });
const owner = service();
expect(await owner.tick()).toMatchObject({ pinned: 1, deleted: 0 });
expect(await owner.nextDeadlineAt()).toBeNull();
expect((await storage.headObject(child.companyId, child.keys[0]!)).exists).toBe(true);
await db.update(executionWorkspaces).set({ metadata: {} }).where(eq(executionWorkspaces.id, workspaceId));
await db.update(executionWorkspaceSnapshots).set({ status: "superseded", deleteAfter: new Date(now.getTime() - 1) })
.where(eq(executionWorkspaceSnapshots.id, parentId));
await owner.tick(); await owner.tick();
expect((await storage.headObject(child.companyId, child.keys[0]!)).exists).toBe(false);
expect(await owner.nextDeadlineAt()).toBeNull();
});
it("purges exact-key S3 versions and delete markers before settling retention", async () => {
const s3 = await versionedS3Fixture();
try {
@@ -23,6 +23,13 @@ async function pinned(db: ReadDb, row: Snapshot) {
eq(executionWorkspaces.companyId, row.companyId), eq(executionWorkspaces.id, row.executionWorkspaceId),
));
if ((workspace?.metadata?.remoteWorkspace as { snapshotId?: unknown } | undefined)?.snapshotId === row.id) return true;
const [importedBy] = await db.select({ id: executionWorkspaceSnapshots.id }).from(executionWorkspaceSnapshots).where(and(
eq(executionWorkspaceSnapshots.companyId, row.companyId), ne(executionWorkspaceSnapshots.status, "deleted"),
sql`${executionWorkspaceSnapshots.metadata}->'importedWorkspaceChildren' @> ${JSON.stringify([
{ workspaceId: row.executionWorkspaceId, snapshotId: row.id },
])}::jsonb`,
)).limit(1);
if (importedBy) return true;
const [operation] = await db.select({ id: workspaceOperations.id }).from(workspaceOperations).where(and(
eq(workspaceOperations.companyId, row.companyId), eq(workspaceOperations.executionWorkspaceId, row.executionWorkspaceId),
or(inArray(workspaceOperations.status, ["running", "pending"]),
@@ -138,7 +145,8 @@ export function remoteWorkspaceSnapshotRetentionService(db: Db, options: {
.where(and(eq(executionWorkspaceSnapshots.id, claimed.id), eq(executionWorkspaceSnapshots.status, "delete_requested")));
// A parent inspected earlier in this page can now lose its last pin.
// Revisit it once instead of waiting for an unrelated execution event.
if (claimed.parentSnapshotId) rescanRequested = true;
if (claimed.parentSnapshotId || Array.isArray(claimed.metadata?.importedWorkspaceChildren)
&& claimed.metadata.importedWorkspaceChildren.length > 0) rescanRequested = true;
result.deleted++;
} catch (error) {
if (signal?.aborted) throw error;