diff --git a/server/src/services/online-workspace-source.ts b/server/src/services/online-workspace-source.ts index 2c8d4a64cdb..3ba9b3b8576 100644 --- a/server/src/services/online-workspace-source.ts +++ b/server/src/services/online-workspace-source.ts @@ -140,7 +140,8 @@ export async function inspectOnlineWorkspaceSource(db: ReadDb, workspace: Worksp const projectIds = new Set(selected.projectIds), physicalIssueIds = new Set(selected.issueIds), aliases = new Set(); const importedChildren: NonNullable = []; try { - for (const child of await inspectImportedWorkspaceSourcePaths(db, workspace)) { + for (const child of await inspectImportedWorkspaceSourcePaths(db, workspace, + [...linkedSources.values()].flatMap(proof => proof.links.map(link => link.root.path)))) { const identities = await workspaceSourceIdentities(workspace.companyId, child.sourcePath); if (identities.unownedPaths.length) return null; for (const id of identities.projectIds) projectIds.add(id); @@ -168,7 +169,8 @@ export async function inspectOnlineWorkspaceSource(db: ReadDb, workspace: Worksp } if (!aliases.has(workspace.id) || aliases.size > 256) return null; const included = nodes.filter(node => aliases.has(node.row.id)); - const registeredRoots = await Promise.all(included.flatMap(node => localWorkspaceSourcePaths(node.row)).map(physicalPath)); + const registeredRoots = await Promise.all([...included.flatMap(node => localWorkspaceSourcePaths(node.row)), + ...importedChildren.map(child => child.sourcePath)].map(physicalPath)); if ([...linkedSources.values()].some(proof => proof.links.some(link => !registeredRoots.some(root => link.root.path === root || link.root.path.startsWith(root + path.sep))))) return null; const issueIds = [...new Set([...physicalIssueIds, ...included.map(node => node.row.sourceIssueId!)])].sort(); @@ -224,7 +226,7 @@ export async function inspectOnlineWorkspaceSource(db: ReadDb, workspace: Worksp } return { kind: "git", root: captureRoot, projectId: null, projectIds: [...projectIds].sort(), issueIds, workspaceIds: [...aliases].sort(), device: entry.dev, inode: entry.ino, gitSourceRoots, companions, - physicalRoots: included.flatMap(node => localWorkspaceSourcePaths(node.row)).sort(), + physicalRoots: [...new Set([...included.flatMap(node => localWorkspaceSourcePaths(node.row)), ...importedChildren.map(child => child.sourcePath)])].sort(), settledPreparedWorkspaceIds: settledPreparedWorkspaceIds.sort(), settledRecoveryIssueIds: [...settledRecoveryIssueIds].sort(), settledFinalizationRunIds: [...new Set(companions.flatMap(proof => proof.sources.map(source => source.runId)))].sort(), gitSourceFingerprints: included.map(node => ({ workspaceId: node.row.id, fingerprint: node.fingerprint })), importedChildren, diff --git a/server/src/services/remote-workspace-migration-tree.ts b/server/src/services/remote-workspace-migration-tree.ts index d7e3880c22a..03e2f4fa19b 100644 --- a/server/src/services/remote-workspace-migration-tree.ts +++ b/server/src/services/remote-workspace-migration-tree.ts @@ -269,7 +269,7 @@ export async function workspaceMigrationLayoutBlockers(db: ReadDb, workspace: Wo return []; } -async function importedWorkspaceCandidates(db: ReadDb, workspace: Workspace) { +async function importedWorkspaceCandidates(db: ReadDb, workspace: Workspace, linkedRoots: readonly string[] = []) { const sourceRoot = rootOf(workspace); if (!sourceRoot) return []; const root = await physicalPath(sourceRoot); @@ -289,12 +289,14 @@ async function importedWorkspaceCandidates(db: ReadDb, workspace: Workspace) { if (!row.operation.cwd) continue; located.push({ ...row, sourcePath: await physicalPath(row.operation.cwd) }); } - return located.filter(row => row.sourcePath.startsWith(`${root}/.paperclip/worktrees/`)) + const linked = new Set(linkedRoots); + return located.filter(row => row.sourcePath.startsWith(`${root}/.paperclip/worktrees/`) || linked.has(row.sourcePath) + && row.operation.finishedAt !== null && row.operation.metadata?.manifestDigest === row.snapshot.manifestDigest) .sort((a, b) => a.sourcePath.length - b.sourcePath.length || a.operation.id.localeCompare(b.operation.id)); } -export async function inspectImportedWorkspaceSourcePaths(db: ReadDb, workspace: Workspace) { - return (await importedWorkspaceCandidates(db, workspace)).map(({ operation, snapshot, sourcePath }) => ({ +export async function inspectImportedWorkspaceSourcePaths(db: ReadDb, workspace: Workspace, linkedRoots: readonly string[] = []) { + return (await importedWorkspaceCandidates(db, workspace, linkedRoots)).map(({ operation, snapshot, sourcePath }) => ({ workspaceId: snapshot.executionWorkspaceId, operationId: operation.id, snapshotId: snapshot.id, sourcePath, manifestDigest: snapshot.manifestDigest, operationManifestDigest: operation.metadata?.manifestDigest, })); diff --git a/server/src/services/remote-workspace-migration.test.ts b/server/src/services/remote-workspace-migration.test.ts index 5f5f643027d..f25df01355c 100644 --- a/server/src/services/remote-workspace-migration.test.ts +++ b/server/src/services/remote-workspace-migration.test.ts @@ -1671,6 +1671,57 @@ describe.skipIf(!support.supported)("remote workspace migration", () => { 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(resolve => { acquire = resolve; }), released = new Promise(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" });