mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
fix(workspaces): verify archived repository intents before publication (#20441)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Workspace migration moves local files into durable, authenticated snapshots. > - Archived Git workspaces can retain an unmaterialized repository request after their local tree is removed. > - Admission cannot prove filesystem absence from the removed tree. > - This pull request checks strict request candidates against the private restored Git tree and the authenticated snapshot readback. > - Publication rechecks current repository rows without changing the request or task history. ## Linked Issues or Issue Description **What happened?** An archived workspace with an authenticated restoration recipe and an unresolved, unmaterialized repository request fails migration with `repository_preparation_unresolved`. Its original directory no longer exists. **Expected behavior** Migration can preserve the request when the actual private restored tree proves its target absent and every parent safe. It must refuse partial materialization, symlinks, or changed request state. **Steps to reproduce** 1. Archive a Git workspace with a valid saved recipe and a pending repository request. 2. Remove its original local workspace directory. 3. Run canonical workspace migration. The previous implementation refuses before it can inspect the restored tree. **Paperclip version or commit** Reproduced on `1ca3e8ea2a759e511ab2d2659339c6c770a7a2ba`. **Deployment mode** Self-hosted server. This is a core workspace migration bug. **Relevant logs or output** `remote_workspace_migration_blocked:repository_preparation_unresolved` Refs #20416, #20424, #20436. These fixes retain their source, recovery, and ownership guards. ## What Changed - Separate strict repository request metadata from filesystem absence proof. - Admit only strict candidates backed by a canonical archived Git recipe to private materialization. - Verify target absence and safe parents before a new upload and after authenticated archive restore. - Lock and recheck current repository rows before authority publication, including recovered uploaded archives. - Preserve previous-release recipes, request rows, task selection, history, provider references, and unresolved recovery obligations. No persisted schema or public option changes. ## Verification - Genuine baseline RED: both pending and needs-repair archived requests fail on unchanged master. - GREEN: 27 focused real-PostgreSQL/FS/Git tests pass. Includes partial/symlink refusal before upload, request mutation during upload, recovered-upload readback, and existing native F/source checks. - Command: `pnpm exec vitest run server/src/services/remote-workspace-migration.test.ts -t 'archived repository intent|unmaterialized repository request|unresolved repository request|pending repository|unselected archived recipe|archived prepared source|competing native F owner'`. - After rebasing onto current master, all six new cases pass on `0b6854789acc067a44e697acebdc04e51b9afe88`. Server layered typecheck, module boundaries, and `git diff --check` pass. A review follow-up adds one pending-path mutation regression (1/1 real-PG pass) without changing product code. Fresh required CI runs on final head `2a30a4b444108488ac8b51d0c46c340ee15227b9`; fresh Greptile is 5/5. Server7 had an unchanged trace-parent assertion failure; that same shard passed on the previous product-identical head and the linked-source sibling PR. The original failure remains recorded. The single targeted server7 retry passed. Final head has 56 successful checks, 4 intentional skips, and 1 neutral review status. ## Risks - Metadata admission is only a candidate check. A candidate still fails unless the private authenticated filesystem proves absence. - Publication keeps repository rows locked while it checks their current paths against the owned restored tree. A real pending-relativePath mutation now tests this final check. Existing workspace/source/native locks remain in place. - Absence is proved only in SAVED data reconstructed from the retained recipe and snapshot. This does not prove that a request never materialized before archival. Existing Git restore points can exclude ignored `.paperclip-repositories` paths outside recorded checkouts. This PR does not change that preexisting coverage or recover bytes that were never saved. - The original recipe, restore point and checkout references remain unchanged. No files are recovered into or removed from the original directory. No request is marked ready or restored. Partial target files present in reconstructed saved data remain blocked and retain their state. ## Model Used OpenAI GPT-6.1 Codex, with reasoning, repository tools, and code execution. The host does not expose an exact 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>
This commit is contained in:
1 parent
b6f4008f9b
commit
29a66d679e
2 files changed
+114
-10
No files matched your search
@@ -637,13 +637,15 @@ describe.skipIf(!support.supported)("remote workspace migration", () => {
|
||||
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") {
|
||||
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 });
|
||||
@@ -696,6 +698,86 @@ describe.skipIf(!support.supported)("remote workspace migration", () => {
|
||||
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("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("repository_preparation_unresolved");
|
||||
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("repository_preparation_unresolved");
|
||||
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.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 });
|
||||
|
||||
@@ -59,14 +59,21 @@ const gitSourceProof = (source: ArchivedGitMigrationSource | null) => source ? {
|
||||
...(source.retainedBranch ? { retainedBranch: source.retainedBranch } : {}),
|
||||
} : null;
|
||||
|
||||
async function isUnmaterializedRepositoryRequest(workspace: Workspace,
|
||||
repository: typeof executionWorkspaceRepositories.$inferSelect) {
|
||||
type RepositoryRequest = typeof executionWorkspaceRepositories.$inferSelect;
|
||||
|
||||
function isUnmaterializedRepositoryCandidate(repository: RepositoryRequest) {
|
||||
const unprepared = repository.state === "pending" && !repository.failureCode
|
||||
|| repository.state === "needs_repair" && repository.failureCode === "repository_preparation_failed";
|
||||
if (!workspace.cwd || !unprepared || repository.pinnedCommit || repository.branchName) return false;
|
||||
if (!unprepared || repository.pinnedCommit || repository.branchName) return false;
|
||||
const relative = repository.relativePath;
|
||||
if (!relative || relative.includes("\\") || path.posix.isAbsolute(relative)
|
||||
|| path.posix.normalize(relative) !== relative || relative.split("/").some(part => !part || part === "." || part === "..")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function isUnmaterializedRepositoryRequest(workspace: Pick<Workspace, "cwd">, repository: RepositoryRequest) {
|
||||
if (!workspace.cwd || !isUnmaterializedRepositoryCandidate(repository)) return false;
|
||||
const relative = repository.relativePath;
|
||||
const root = await fs.lstat(workspace.cwd).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
@@ -88,6 +95,13 @@ async function isUnmaterializedRepositoryRequest(workspace: Workspace,
|
||||
});
|
||||
}
|
||||
|
||||
async function assertArchivedRepositoryIntents(root: string, repositories: readonly RepositoryRequest[]) {
|
||||
for (const repository of repositories) {
|
||||
if (!await isUnmaterializedRepositoryRequest({ cwd: root }, repository))
|
||||
throw new Error("remote_workspace_migration_blocked:repository_preparation_unresolved");
|
||||
}
|
||||
}
|
||||
|
||||
async function inspect(db: ReadDb, input: { companyId: string; workspaceId: string; operationId?: string; writersStopped?: boolean; onlineSource?: OnlineWorkspaceSource; onlineSourceUnproven?: boolean }, cohort?: WorkspaceMigrationCohort) {
|
||||
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");
|
||||
@@ -95,7 +109,7 @@ async function inspect(db: ReadDb, input: { companyId: string; workspaceId: stri
|
||||
const marker = object(workspace.metadata?.remoteWorkspaceMigration);
|
||||
const allowedOperation = input.operationId ?? (marker.schema === SCHEMA && typeof marker.operationId === "string" ? marker.operationId : undefined);
|
||||
const reasons: string[] = [];
|
||||
if (authority) return { workspace, reasons, authority, marker, source: authority.source, issueBinding: null, retainedDirectory: null, archivedGitSource: null, legacyRecoverySources: [] as LegacyWorkspaceRecoverySource[], finalizedRecoveryHolds: [] as FinalizedWorkspaceRecoveryHold[], onlineRecoveryHolds: [] as OnlineRecoveryHold[] };
|
||||
if (authority) return { workspace, reasons, authority, marker, source: authority.source, issueBinding: null, retainedDirectory: null, archivedGitSource: null, legacyRecoverySources: [] as LegacyWorkspaceRecoverySource[], finalizedRecoveryHolds: [] as FinalizedWorkspaceRecoveryHold[], onlineRecoveryHolds: [] as OnlineRecoveryHold[], archivedRepositoryIntents: [] as RepositoryRequest[] };
|
||||
if (input.writersStopped) reasons.push(...await offlineWorkspaceMigrationBlockers(db));
|
||||
if (input.onlineSourceUnproven) reasons.push("online_workspace_source_scope_unproven");
|
||||
if (input.onlineSource) {
|
||||
@@ -301,7 +315,9 @@ async function inspect(db: ReadDb, input: { companyId: string; workspaceId: stri
|
||||
legacyRecoverySources.sort((a, b) => a.runId.localeCompare(b.runId));
|
||||
const repositories = await db.select().from(executionWorkspaceRepositories).where(and(eq(executionWorkspaceRepositories.companyId, workspace.companyId),
|
||||
eq(executionWorkspaceRepositories.executionWorkspaceId, workspace.id)));
|
||||
const archivedRepositoryIntents: RepositoryRequest[] = [];
|
||||
for (const repository of repositories.filter(repository => repository.state !== "ready")) {
|
||||
if (archivedGitSource?.recipe && isUnmaterializedRepositoryCandidate(repository)) { archivedRepositoryIntents.push(repository); continue; }
|
||||
if ((!input.writersStopped && !input.onlineSource) || !await isUnmaterializedRepositoryRequest(workspace, repository)) reasons.push("repository_preparation_unresolved");
|
||||
}
|
||||
const source: RemoteWorkspaceSource = workspace.repoUrl ? { kind: "git", repoUrl: workspace.repoUrl,
|
||||
@@ -309,7 +325,7 @@ async function inspect(db: ReadDb, input: { companyId: string; workspaceId: stri
|
||||
const materialized = repositories.filter(repository => repository.state === "ready");
|
||||
if (materialized.length) source.repositories = materialized.map(repository => ({ id: repository.id, relativePath: repository.relativePath,
|
||||
repoUrl: repository.repoUrl, ref: repository.requestedRef, ...(repository.pinnedCommit ? { pinnedCommit: repository.pinnedCommit } : {}) }));
|
||||
return { workspace, reasons: [...new Set(reasons)], authority, marker, source, issueBinding: issue?.executionWorkspaceId ?? null, retainedDirectory, archivedGitSource, legacyRecoverySources, finalizedRecoveryHolds, onlineRecoveryHolds };
|
||||
return { workspace, reasons: [...new Set(reasons)], authority, marker, source, issueBinding: issue?.executionWorkspaceId ?? null, retainedDirectory, archivedGitSource, legacyRecoverySources, finalizedRecoveryHolds, onlineRecoveryHolds, archivedRepositoryIntents };
|
||||
}
|
||||
|
||||
export async function inventoryRemoteWorkspaceMigration(db: Db, input: { companyId: string; workspaceId: string; writersStopped?: boolean }) {
|
||||
@@ -365,7 +381,7 @@ async function reserveMigration(input: MigrationInput, onlineSource?: OnlineWork
|
||||
if (!workspace) throw new Error("remote_workspace_migration_workspace_missing");
|
||||
if (workspace.sourceIssueId !== selected.sourceIssueId) throw new Error("remote_workspace_migration_source_changed");
|
||||
const current = await inspect(tx as unknown as Db, { ...input, onlineSource }, cohort);
|
||||
if (current.authority) return { workspace, authority: current.authority, operation: null, source: current.source, sourceFingerprint: "", issueBinding: current.issueBinding, retainedDirectory: null, archivedGitSource: null, legacyRecoverySources: [] as LegacyWorkspaceRecoverySource[], finalizedRecoveryHolds: [] as FinalizedWorkspaceRecoveryHold[], onlineRecoveryHolds: [] as OnlineRecoveryHold[] };
|
||||
if (current.authority) return { workspace, authority: current.authority, operation: null, source: current.source, sourceFingerprint: "", issueBinding: current.issueBinding, retainedDirectory: null, archivedGitSource: null, legacyRecoverySources: [] as LegacyWorkspaceRecoverySource[], finalizedRecoveryHolds: [] as FinalizedWorkspaceRecoveryHold[], onlineRecoveryHolds: [] as OnlineRecoveryHold[], archivedRepositoryIntents: [] as RepositoryRequest[] };
|
||||
if (current.reasons.length) throw new Error(`remote_workspace_migration_blocked:${current.reasons.join(",")}`);
|
||||
const nativeSessionContinuity = await inspectNativeWorkspaceMigration(tx as unknown as Db, workspace);
|
||||
const source = remoteWorkspaceSourceSchema.parse(input.source ?? current.source);
|
||||
@@ -390,7 +406,7 @@ async function reserveMigration(input: MigrationInput, onlineSource?: OnlineWork
|
||||
if (canonical(recoverySourcesResumeIdentity(operation.metadata?.legacyRecoverySources ?? []))
|
||||
!== canonical(recoverySourcesResumeIdentity(current.legacyRecoverySources))) throw new Error("remote_workspace_migration_source_changed");
|
||||
if (canonical(operation.metadata?.onlineRecoveryHolds ?? []) !== canonical(current.onlineRecoveryHolds)) throw new Error("remote_workspace_migration_source_changed");
|
||||
return { workspace, authority: null, operation, source, sourceFingerprint, issueBinding, retainedDirectory: current.retainedDirectory, archivedGitSource: current.archivedGitSource, legacyRecoverySources: current.legacyRecoverySources, finalizedRecoveryHolds: current.finalizedRecoveryHolds, onlineRecoveryHolds: current.onlineRecoveryHolds };
|
||||
return { workspace, authority: null, operation, source, sourceFingerprint, issueBinding, retainedDirectory: current.retainedDirectory, archivedGitSource: current.archivedGitSource, legacyRecoverySources: current.legacyRecoverySources, finalizedRecoveryHolds: current.finalizedRecoveryHolds, onlineRecoveryHolds: current.onlineRecoveryHolds, archivedRepositoryIntents: current.archivedRepositoryIntents };
|
||||
}
|
||||
const [operation] = await tx.insert(workspaceOperations).values({ ...(cohort ? { id: cohort.members.find(member => member.workspaceId === input.workspaceId)!.operationId } : {}), companyId: workspace.companyId, executionWorkspaceId: workspace.id,
|
||||
issueId: workspace.sourceIssueId, cwd: workspace.cwd ?? workspace.providerRef, phase: "remote_workspace_migration", status: "running",
|
||||
@@ -408,7 +424,7 @@ async function reserveMigration(input: MigrationInput, onlineSource?: OnlineWork
|
||||
await tx.update(executionWorkspaces).set({ metadata: { ...workspace.metadata, remoteWorkspaceMigration: {
|
||||
schema: SCHEMA, operationId: operation!.id, state: "running", sourceFingerprint, source,
|
||||
} }, updatedAt: new Date() }).where(eq(executionWorkspaces.id, workspace.id));
|
||||
return { workspace, authority: null, operation: operation!, source, sourceFingerprint, issueBinding: current.issueBinding, retainedDirectory: current.retainedDirectory, archivedGitSource: current.archivedGitSource, legacyRecoverySources: current.legacyRecoverySources, finalizedRecoveryHolds: current.finalizedRecoveryHolds, onlineRecoveryHolds: current.onlineRecoveryHolds };
|
||||
return { workspace, authority: null, operation: operation!, source, sourceFingerprint, issueBinding: current.issueBinding, retainedDirectory: current.retainedDirectory, archivedGitSource: current.archivedGitSource, legacyRecoverySources: current.legacyRecoverySources, finalizedRecoveryHolds: current.finalizedRecoveryHolds, onlineRecoveryHolds: current.onlineRecoveryHolds, archivedRepositoryIntents: current.archivedRepositoryIntents };
|
||||
});
|
||||
}
|
||||
type ReservedMigration = Awaited<ReturnType<typeof reserveMigration>>;
|
||||
@@ -505,6 +521,7 @@ async function captureMigration(input: MigrationInput, reserved: ReservedMigrati
|
||||
if (!saved) throw new Error("remote_workspace_migration_operation_changed");
|
||||
operation = saved;
|
||||
}
|
||||
if (sourceRoot) await assertArchivedRepositoryIntents(sourceRoot, reserved.archivedRepositoryIntents);
|
||||
const exclude = nested.map(child => child.relativePath);
|
||||
const captured = recovered ?? await captureRemoteWorkspaceDirectory({ cwd: sourceRoot!, archivePath, exclude, requireNoGit: onlineSource?.kind === "ordinary", gitSourceRoots: onlineSource?.gitSourceRoots, signal: input.signal });
|
||||
const restoredArchive = path.join(scratch, "verified.pcws"), restored = path.join(scratch, "restored");
|
||||
@@ -520,6 +537,7 @@ async function captureMigration(input: MigrationInput, reserved: ReservedMigrati
|
||||
if (!verifiedArchivePath) await loadWorkspaceSnapshotArchive({ storage: input.storage, reference: ref,
|
||||
targetPath: restoredArchive, signal: input.signal });
|
||||
await restoreRemoteWorkspaceArchive({ archivePath: restoredArchive, targetDir: restored, expectedManifestDigest: ref.manifestDigest, signal: input.signal });
|
||||
await assertArchivedRepositoryIntents(restored, reserved.archivedRepositoryIntents);
|
||||
if (!recovered) {
|
||||
const currentArchive = path.join(scratch, "current.pcws");
|
||||
const current = await captureRemoteWorkspaceDirectory({ cwd: sourceRoot!, archivePath: currentArchive, exclude, requireNoGit: onlineSource?.kind === "ordinary", gitSourceRoots: onlineSource?.gitSourceRoots, signal: input.signal });
|
||||
@@ -532,7 +550,7 @@ async function captureMigration(input: MigrationInput, reserved: ReservedMigrati
|
||||
if (canonical(verified) !== canonical(nested)) throw new Error("nested_workspace_source_changed");
|
||||
}
|
||||
input.signal?.throwIfAborted();
|
||||
return { operation, ref, nested, scratch };
|
||||
return { operation, ref, nested, scratch, restored };
|
||||
} catch (error) { await removeTaskDirectoryTree(scratch); throw error; }
|
||||
}
|
||||
type CapturedMigration = Awaited<ReturnType<typeof captureMigration>>;
|
||||
@@ -574,12 +592,16 @@ async function validateMigrationPublication(tx: MigrationTransaction, input: Mig
|
||||
.from(environmentLeases).where(and(eq(environmentLeases.companyId, input.companyId),
|
||||
eq(environmentLeases.id, allocation.leaseId))).for("update", { noWait: true });
|
||||
}
|
||||
if (reserved.archivedGitSource?.recipe) await tx.select({ id: executionWorkspaceRepositories.id }).from(executionWorkspaceRepositories)
|
||||
.where(and(eq(executionWorkspaceRepositories.companyId, input.companyId), eq(executionWorkspaceRepositories.executionWorkspaceId, input.workspaceId)))
|
||||
.orderBy(executionWorkspaceRepositories.id).for("update", { noWait: true });
|
||||
const validation = await inspect(tx as unknown as Db, { ...input, operationId: operation.id, onlineSource }, cohort);
|
||||
if (!input.source && canonical(validation.source) !== canonical(reserved.source)) throw new Error("remote_workspace_migration_source_changed");
|
||||
if (validation.issueBinding !== reserved.issueBinding) throw new Error("remote_workspace_migration_source_changed");
|
||||
if (Boolean(validation.retainedDirectory) !== Boolean(reserved.retainedDirectory)) throw new Error("remote_workspace_migration_source_changed");
|
||||
if (canonical(gitSourceProof(validation.archivedGitSource)) !== canonical(gitSourceProof(reserved.archivedGitSource))) throw new Error("remote_workspace_migration_source_changed");
|
||||
if (validation.reasons.length) throw new Error(`remote_workspace_migration_blocked:${validation.reasons.join(",")}`);
|
||||
await assertArchivedRepositoryIntents(captured.restored, validation.archivedRepositoryIntents);
|
||||
if (reserved.archivedGitSource) await lockArchivedGitMigrationSources(tx as unknown as Db, workspace, reserved.archivedGitSource);
|
||||
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");
|
||||
|
||||
Reference in new issue
Block a user