diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index ca11225e7bc..1ff4e738c29 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -21,6 +21,7 @@ import { eq } from "drizzle-orm"; import { buildWorkspaceRuntimeDesiredStatePatch, cleanupExecutionWorkspaceArtifacts, + ensurePersistedExecutionWorkspaceAvailable, ensureServerWorkspaceLinksCurrent, ensureRuntimeServicesForRun, listConfiguredRuntimeServiceEntries, @@ -1447,6 +1448,187 @@ describe("realizeExecutionWorkspace", () => { expect(actualHead).toBe(expectedHead); }); + it("reattaches a missing persisted git worktree before manual control starts it", async () => { + const repoRoot = await createTempRepo(); + const branchName = "PAP-451-restore-persisted-worktree"; + await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); + await fs.writeFile( + path.join(repoRoot, "scripts", "restore.sh"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BRANCH\" > .paperclip-restored-branch", + ].join("\n"), + "utf8", + ); + await fs.chmod(path.join(repoRoot, "scripts", "restore.sh"), 0o755); + await runGit(repoRoot, ["add", "scripts/restore.sh"]); + await runGit(repoRoot, ["commit", "-m", "Add restore script"]); + + await runGit(repoRoot, ["checkout", "-b", branchName]); + await fs.writeFile(path.join(repoRoot, "feature.txt"), "persisted\n", "utf8"); + await runGit(repoRoot, ["add", "feature.txt"]); + await runGit(repoRoot, ["commit", "-m", "Add persisted feature"]); + const expectedHead = (await execFileAsync("git", ["rev-parse", branchName], { cwd: repoRoot })).stdout.trim(); + await runGit(repoRoot, ["checkout", "main"]); + + const initial = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + provisionCommand: "bash ./scripts/restore.sh", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-451", + title: "Restore persisted worktree", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + await fs.rm(initial.cwd, { recursive: true, force: true }); + + const restored = await ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + workspace: { + mode: "isolated_workspace", + strategyType: "git_worktree", + cwd: initial.cwd, + providerRef: initial.worktreePath, + projectId: "project-1", + projectWorkspaceId: "workspace-1", + repoUrl: null, + baseRef: "HEAD", + branchName, + config: { + provisionCommand: "bash ./scripts/restore.sh", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-451", + title: "Restore persisted worktree", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + expect(restored).not.toBeNull(); + expect(restored?.cwd).toBe(initial.cwd); + await expect(fs.readFile(path.join(initial.cwd, "feature.txt"), "utf8")).resolves.toBe("persisted\n"); + await expect(fs.readFile(path.join(initial.cwd, ".paperclip-restored-branch"), "utf8")).resolves.toBe(`${branchName}\n`); + const actualHead = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: initial.cwd })).stdout.trim(); + expect(actualHead).toBe(expectedHead); + }); + + it("reprovisions an existing persisted git worktree before manual control starts it", async () => { + const repoRoot = await createTempRepo(); + await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); + await fs.writeFile( + path.join(repoRoot, "scripts", "restore.sh"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "printf 'reprovisioned\\n' > .paperclip-restored-state", + ].join("\n"), + "utf8", + ); + await fs.chmod(path.join(repoRoot, "scripts", "restore.sh"), 0o755); + await runGit(repoRoot, ["add", "scripts/restore.sh"]); + await runGit(repoRoot, ["commit", "-m", "Add reprovision script"]); + + const initial = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + provisionCommand: "bash ./scripts/restore.sh", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-452", + title: "Reprovision persisted worktree", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + await fs.rm(path.join(initial.cwd, ".paperclip-restored-state"), { force: true }); + + await ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + workspace: { + mode: "isolated_workspace", + strategyType: "git_worktree", + cwd: initial.cwd, + providerRef: initial.worktreePath, + projectId: "project-1", + projectWorkspaceId: "workspace-1", + repoUrl: null, + baseRef: "HEAD", + branchName: initial.branchName, + config: { + provisionCommand: "bash ./scripts/restore.sh", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-452", + title: "Reprovision persisted worktree", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + await expect(fs.readFile(path.join(initial.cwd, ".paperclip-restored-state"), "utf8")).resolves.toBe("reprovisioned\n"); + }); + it("auto-detects the default branch when baseRef is not configured", async () => { // Create a repo with "master" as default branch (not "main") const repoRoot = await createTempRepo("master"); diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index 26e35cfa44f..963be2ed7be 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -16,6 +16,7 @@ import { readProjectWorkspaceRuntimeConfig } from "../services/project-workspace import { buildWorkspaceRuntimeDesiredStatePatch, cleanupExecutionWorkspaceArtifacts, + ensurePersistedExecutionWorkspaceAvailable, listConfiguredRuntimeServiceEntries, runWorkspaceJobForControl, startRuntimeServicesForWorkspaceControl, @@ -123,6 +124,20 @@ export function executionWorkspaceRoutes(db: Db) { const projectWorkspaceRuntime = readProjectWorkspaceRuntimeConfig( (projectWorkspace?.metadata as Record | null) ?? null, )?.workspaceRuntime ?? null; + const projectPolicy = existing.projectId + ? await db + .select({ + executionWorkspacePolicy: projects.executionWorkspacePolicy, + }) + .from(projects) + .where( + and( + eq(projects.id, existing.projectId), + eq(projects.companyId, existing.companyId), + ), + ) + .then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)) + : null; const effectiveRuntimeConfig = existing.config?.workspaceRuntime ?? projectWorkspaceRuntime ?? null; const target = req.body as { workspaceCommandId?: string | null; runtimeServiceId?: string | null; serviceIndex?: number | null }; const configuredServices = effectiveRuntimeConfig @@ -197,10 +212,57 @@ export function executionWorkspaceRoutes(db: Db) { serviceIndex: selectedServiceIndex, }, run: async () => { + const ensureWorkspaceAvailable = async () => + await ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd: projectWorkspace?.cwd ?? workspaceCwd, + source: existing.mode === "shared_workspace" ? "project_primary" : "task_session", + projectId: existing.projectId, + workspaceId: existing.projectWorkspaceId, + repoUrl: existing.repoUrl, + repoRef: existing.baseRef, + }, + workspace: { + mode: existing.mode, + strategyType: existing.strategyType, + cwd: existing.cwd, + providerRef: existing.providerRef, + projectId: existing.projectId, + projectWorkspaceId: existing.projectWorkspaceId, + repoUrl: existing.repoUrl, + baseRef: existing.baseRef, + branchName: existing.branchName, + config: { + ...existing.config, + provisionCommand: + existing.config?.provisionCommand + ?? projectPolicy?.workspaceStrategy?.provisionCommand + ?? null, + }, + }, + issue: existing.sourceIssueId + ? { + id: existing.sourceIssueId, + identifier: null, + title: existing.name, + } + : null, + agent: { + id: actor.agentId ?? null, + name: actor.actorType === "user" ? "Board" : "Agent", + companyId: existing.companyId, + }, + recorder, + }); + if (action === "run") { if (!workspaceCommand || workspaceCommand.kind !== "job") { throw new Error("Workspace job selection is required"); } + const availableWorkspace = await ensureWorkspaceAvailable(); + if (!availableWorkspace) { + throw new Error("Execution workspace needs a local path before Paperclip can run workspace commands"); + } return await runWorkspaceJobForControl({ actor: { id: actor.agentId ?? null, @@ -214,20 +276,7 @@ export function executionWorkspaceRoutes(db: Db) { title: existing.name, } : null, - workspace: { - baseCwd: workspaceCwd, - source: existing.mode === "shared_workspace" ? "project_primary" : "task_session", - projectId: existing.projectId, - workspaceId: existing.projectWorkspaceId, - repoUrl: existing.repoUrl, - repoRef: existing.baseRef, - strategy: existing.strategyType === "git_worktree" ? "git_worktree" : "project_primary", - cwd: workspaceCwd, - branchName: existing.branchName, - worktreePath: existing.strategyType === "git_worktree" ? workspaceCwd : null, - warnings: [], - created: false, - }, + workspace: availableWorkspace, command: workspaceCommand.rawConfig, adapterEnv: {}, recorder, @@ -261,6 +310,10 @@ export function executionWorkspaceRoutes(db: Db) { } if (action === "start" || action === "restart") { + const availableWorkspace = await ensureWorkspaceAvailable(); + if (!availableWorkspace) { + throw new Error("Execution workspace needs a local path before Paperclip can manage local runtime services"); + } const startedServices = await startRuntimeServicesForWorkspaceControl({ db, actor: { @@ -275,20 +328,7 @@ export function executionWorkspaceRoutes(db: Db) { title: existing.name, } : null, - workspace: { - baseCwd: workspaceCwd, - source: existing.mode === "shared_workspace" ? "project_primary" : "task_session", - projectId: existing.projectId, - workspaceId: existing.projectWorkspaceId, - repoUrl: existing.repoUrl, - repoRef: existing.baseRef, - strategy: existing.strategyType === "git_worktree" ? "git_worktree" : "project_primary", - cwd: workspaceCwd, - branchName: existing.branchName, - worktreePath: existing.strategyType === "git_worktree" ? workspaceCwd : null, - warnings: [], - created: false, - }, + workspace: availableWorkspace, executionWorkspaceId: existing.id, config: { workspaceRuntime: effectiveRuntimeConfig }, adapterEnv: {}, diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index b7dac8d69b6..fc9aa64e51b 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -1160,6 +1160,147 @@ export async function realizeExecutionWorkspace(input: { }; } +export async function ensurePersistedExecutionWorkspaceAvailable(input: { + base: ExecutionWorkspaceInput; + workspace: { + mode: string | null | undefined; + strategyType: string | null | undefined; + cwd: string | null | undefined; + providerRef: string | null | undefined; + projectId: string | null | undefined; + projectWorkspaceId: string | null | undefined; + repoUrl: string | null | undefined; + baseRef: string | null | undefined; + branchName: string | null | undefined; + config?: { + provisionCommand?: string | null; + } | null; + }; + issue: ExecutionWorkspaceIssueRef | null; + agent: ExecutionWorkspaceAgentRef; + recorder?: WorkspaceOperationRecorder | null; +}): Promise { + const cwd = asString(input.workspace.cwd ?? input.workspace.providerRef, "").trim(); + if (!cwd) return null; + + const strategy = input.workspace.strategyType === "git_worktree" ? "git_worktree" : "project_primary"; + const realized: RealizedExecutionWorkspace = { + baseCwd: input.base.baseCwd, + source: input.workspace.mode === "shared_workspace" ? "project_primary" : "task_session", + projectId: input.workspace.projectId ?? input.base.projectId, + workspaceId: input.workspace.projectWorkspaceId ?? input.base.workspaceId, + repoUrl: input.workspace.repoUrl ?? input.base.repoUrl, + repoRef: input.workspace.baseRef ?? input.base.repoRef, + strategy, + cwd, + branchName: input.workspace.branchName ?? null, + worktreePath: strategy === "git_worktree" ? (input.workspace.providerRef ?? cwd) : null, + warnings: [], + created: false, + }; + const provisionCommand = asString(input.workspace.config?.provisionCommand, "").trim(); + + if (strategy !== "git_worktree") { + return realized; + } + if (await directoryExists(cwd)) { + if (provisionCommand) { + const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd); + await provisionExecutionWorktree({ + strategy: { + type: "git_worktree", + provisionCommand, + }, + base: input.base, + repoRoot, + worktreePath: realized.worktreePath ?? cwd, + branchName: realized.branchName ?? "", + issue: input.issue, + agent: input.agent, + created: false, + recorder: input.recorder ?? null, + }); + } + return realized; + } + + const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd); + const worktreePath = realized.worktreePath ?? cwd; + const branchName = asString(input.workspace.branchName, "").trim(); + if (!branchName) { + throw new Error(`Execution workspace "${cwd}" is missing and cannot be restored because no branch name is recorded.`); + } + + await fs.mkdir(path.dirname(worktreePath), { recursive: true }); + await runGit(["worktree", "prune"], repoRoot).catch(() => {}); + + let created = false; + try { + await recordGitOperation(input.recorder, { + phase: "worktree_prepare", + args: ["worktree", "add", worktreePath, branchName], + cwd: repoRoot, + metadata: { + repoRoot, + worktreePath, + branchName, + baseRef: input.workspace.baseRef ?? input.base.repoRef ?? null, + created: false, + restored: true, + }, + successMessage: `Reattached missing git worktree at ${worktreePath}\n`, + failureLabel: `git worktree add ${worktreePath}`, + }); + } catch (error) { + if ( + !gitErrorIncludes(error, "invalid reference") + && !gitErrorIncludes(error, "not a commit") + && !gitErrorIncludes(error, "unknown revision") + ) { + throw error; + } + const baseRef = input.workspace.baseRef ?? await detectDefaultBranch(repoRoot) ?? "HEAD"; + await recordGitOperation(input.recorder, { + phase: "worktree_prepare", + args: ["worktree", "add", "-b", branchName, worktreePath, baseRef], + cwd: repoRoot, + metadata: { + repoRoot, + worktreePath, + branchName, + baseRef, + created: true, + restored: true, + }, + successMessage: `Recreated missing git worktree at ${worktreePath}\n`, + failureLabel: `git worktree add ${worktreePath}`, + }); + created = true; + } + + await provisionExecutionWorktree({ + strategy: { + type: "git_worktree", + ...(provisionCommand ? { provisionCommand } : {}), + }, + base: input.base, + repoRoot, + worktreePath, + branchName, + issue: input.issue, + agent: input.agent, + created, + recorder: input.recorder ?? null, + }); + + return { + ...realized, + cwd: worktreePath, + worktreePath, + created, + }; +} + export async function cleanupExecutionWorkspaceArtifacts(input: { workspace: { id: string;