diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 334306c2..7d35e542 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -168,6 +168,8 @@ paperclipai worktree init --from-data-dir ~/.paperclip paperclipai worktree init --force ``` +For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants. + ## Quick Health Checks In another terminal: diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 7eca6611..f2aa023c 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -7,6 +7,8 @@ export interface ExecutionWorkspaceStrategy { baseRef?: string | null; branchTemplate?: string | null; worktreeParentDir?: string | null; + provisionCommand?: string | null; + teardownCommand?: string | null; } export interface ProjectExecutionWorkspacePolicy { diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 58b6eb39..3e269e04 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -7,6 +7,8 @@ const executionWorkspaceStrategySchema = z baseRef: z.string().optional().nullable(), branchTemplate: z.string().optional().nullable(), worktreeParentDir: z.string().optional().nullable(), + provisionCommand: z.string().optional().nullable(), + teardownCommand: z.string().optional().nullable(), }) .strict(); diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index ee027e64..da375495 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -7,6 +7,8 @@ const executionWorkspaceStrategySchema = z baseRef: z.string().optional().nullable(), branchTemplate: z.string().optional().nullable(), worktreeParentDir: z.string().optional().nullable(), + provisionCommand: z.string().optional().nullable(), + teardownCommand: z.string().optional().nullable(), }) .strict(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1dd1ddc..dbc06553 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@playwright/test': specifier: ^1.58.2 version: 1.58.2 + cross-env: + specifier: ^10.1.0 + version: 10.1.0 esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -68,6 +71,9 @@ importers: drizzle-orm: specifier: 0.38.4 version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4) + embedded-postgres: + specifier: ^18.1.0-beta.16 + version: 18.1.0-beta.16 picocolors: specifier: ^1.1.1 version: 1.1.1 @@ -321,6 +327,9 @@ importers: '@types/ws': specifier: ^8.18.1 version: 8.18.1 + cross-env: + specifier: ^10.1.0 + version: 10.1.0 supertest: specifier: ^7.0.0 version: 7.2.2 @@ -989,6 +998,9 @@ packages: cpu: [x64] os: [win32] + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -3424,6 +3436,11 @@ packages: crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -6741,6 +6758,8 @@ snapshots: '@embedded-postgres/windows-x64@18.1.0-beta.16': optional: true + '@epic-web/invariant@1.0.0': {} + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -9255,6 +9274,11 @@ snapshots: crelt@1.0.6: {} + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh new file mode 100644 index 00000000..14a31349 --- /dev/null +++ b/scripts/provision-worktree.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +base_cwd="${PAPERCLIP_WORKSPACE_BASE_CWD:?PAPERCLIP_WORKSPACE_BASE_CWD is required}" +worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}" + +if [[ ! -d "$base_cwd" ]]; then + echo "Base workspace does not exist: $base_cwd" >&2 + exit 1 +fi + +if [[ ! -d "$worktree_cwd" ]]; then + echo "Derived worktree does not exist: $worktree_cwd" >&2 + exit 1 +fi + +while IFS= read -r relative_path; do + [[ -n "$relative_path" ]] || continue + source_path="$base_cwd/$relative_path" + target_path="$worktree_cwd/$relative_path" + + [[ -d "$source_path" ]] || continue + [[ -e "$target_path" || -L "$target_path" ]] && continue + + mkdir -p "$(dirname "$target_path")" + ln -s "$source_path" "$target_path" +done < <( + cd "$base_cwd" && + find . \ + -mindepth 1 \ + -maxdepth 3 \ + -type d \ + -name node_modules \ + ! -path './.git/*' \ + ! -path './.paperclip/*' \ + | sed 's#^\./##' +) diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts index d7ba88e0..a4afe287 100644 --- a/server/src/__tests__/execution-workspace-policy.test.ts +++ b/server/src/__tests__/execution-workspace-policy.test.ts @@ -62,6 +62,7 @@ describe("execution workspace policy helpers", () => { workspaceStrategy: { type: "git_worktree", baseRef: "origin/main", + provisionCommand: "bash ./scripts/provision-worktree.sh", }, workspaceRuntime: { services: [{ name: "web", command: "pnpm dev" }], @@ -75,6 +76,7 @@ describe("execution workspace policy helpers", () => { expect(result.workspaceStrategy).toEqual({ type: "git_worktree", baseRef: "origin/main", + provisionCommand: "bash ./scripts/provision-worktree.sh", }); expect(result.workspaceRuntime).toEqual({ services: [{ name: "web", command: "pnpm dev" }], @@ -116,6 +118,8 @@ describe("execution workspace policy helpers", () => { workspaceStrategy: { type: "git_worktree", worktreeParentDir: ".paperclip/worktrees", + provisionCommand: "bash ./scripts/provision-worktree.sh", + teardownCommand: "bash ./scripts/teardown-worktree.sh", }, }), ).toEqual({ @@ -124,6 +128,8 @@ describe("execution workspace policy helpers", () => { workspaceStrategy: { type: "git_worktree", worktreeParentDir: ".paperclip/worktrees", + provisionCommand: "bash ./scripts/provision-worktree.sh", + teardownCommand: "bash ./scripts/teardown-worktree.sh", }, }); expect( diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index e148d664..ea01c1b9 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -125,6 +125,92 @@ describe("realizeExecutionWorkspace", () => { expect(second.cwd).toBe(first.cwd); expect(second.branchName).toBe(first.branchName); }); + + it("runs a configured provision command inside the derived worktree", async () => { + const repoRoot = await createTempRepo(); + await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); + await fs.writeFile( + path.join(repoRoot, "scripts", "provision.sh"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BRANCH\" > .paperclip-provision-branch", + "printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BASE_CWD\" > .paperclip-provision-base", + "printf '%s\\n' \"$PAPERCLIP_WORKSPACE_CREATED\" > .paperclip-provision-created", + ].join("\n"), + "utf8", + ); + await runGit(repoRoot, ["add", "scripts/provision.sh"]); + await runGit(repoRoot, ["commit", "-m", "Add worktree provision script"]); + + const workspace = 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/provision.sh", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-448", + title: "Run provision command", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-branch"), "utf8")).resolves.toBe( + "PAP-448-run-provision-command\n", + ); + await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-base"), "utf8")).resolves.toBe( + `${repoRoot}\n`, + ); + await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe( + "true\n", + ); + + const reused = 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/provision.sh", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-448", + title: "Run provision command", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n"); + }); }); describe("ensureRuntimeServicesForRun", () => { diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index 76e8045b..f4552af3 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -24,6 +24,8 @@ function parseExecutionWorkspaceStrategy(raw: unknown): ExecutionWorkspaceStrate ...(typeof parsed.baseRef === "string" ? { baseRef: parsed.baseRef } : {}), ...(typeof parsed.branchTemplate === "string" ? { branchTemplate: parsed.branchTemplate } : {}), ...(typeof parsed.worktreeParentDir === "string" ? { worktreeParentDir: parsed.worktreeParentDir } : {}), + ...(typeof parsed.provisionCommand === "string" ? { provisionCommand: parsed.provisionCommand } : {}), + ...(typeof parsed.teardownCommand === "string" ? { teardownCommand: parsed.teardownCommand } : {}), }; } diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 8c9d875c..6f53a164 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -236,6 +236,100 @@ async function directoryExists(value: string) { return fs.stat(value).then((stats) => stats.isDirectory()).catch(() => false); } +function buildWorkspaceCommandEnv(input: { + base: ExecutionWorkspaceInput; + repoRoot: string; + worktreePath: string; + branchName: string; + issue: ExecutionWorkspaceIssueRef | null; + agent: ExecutionWorkspaceAgentRef; + created: boolean; +}) { + const env: NodeJS.ProcessEnv = { ...process.env }; + env.PAPERCLIP_WORKSPACE_CWD = input.worktreePath; + env.PAPERCLIP_WORKSPACE_PATH = input.worktreePath; + env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = input.worktreePath; + env.PAPERCLIP_WORKSPACE_BRANCH = input.branchName; + env.PAPERCLIP_WORKSPACE_BASE_CWD = input.base.baseCwd; + env.PAPERCLIP_WORKSPACE_REPO_ROOT = input.repoRoot; + env.PAPERCLIP_WORKSPACE_SOURCE = input.base.source; + env.PAPERCLIP_WORKSPACE_REPO_REF = input.base.repoRef ?? ""; + env.PAPERCLIP_WORKSPACE_REPO_URL = input.base.repoUrl ?? ""; + env.PAPERCLIP_WORKSPACE_CREATED = input.created ? "true" : "false"; + env.PAPERCLIP_PROJECT_ID = input.base.projectId ?? ""; + env.PAPERCLIP_PROJECT_WORKSPACE_ID = input.base.workspaceId ?? ""; + env.PAPERCLIP_AGENT_ID = input.agent.id; + env.PAPERCLIP_AGENT_NAME = input.agent.name; + env.PAPERCLIP_COMPANY_ID = input.agent.companyId; + env.PAPERCLIP_ISSUE_ID = input.issue?.id ?? ""; + env.PAPERCLIP_ISSUE_IDENTIFIER = input.issue?.identifier ?? ""; + env.PAPERCLIP_ISSUE_TITLE = input.issue?.title ?? ""; + return env; +} + +async function runWorkspaceCommand(input: { + command: string; + cwd: string; + env: NodeJS.ProcessEnv; + label: string; +}) { + const shell = process.env.SHELL?.trim() || "/bin/sh"; + const proc = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve, reject) => { + const child = spawn(shell, ["-c", input.command], { + cwd: input.cwd, + env: input.env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + child.on("error", reject); + child.on("close", (code) => resolve({ stdout, stderr, code })); + }); + if (proc.code === 0) return; + + const details = [proc.stderr.trim(), proc.stdout.trim()].filter(Boolean).join("\n"); + throw new Error( + details.length > 0 + ? `${input.label} failed: ${details}` + : `${input.label} failed with exit code ${proc.code ?? -1}`, + ); +} + +async function provisionExecutionWorktree(input: { + strategy: Record; + base: ExecutionWorkspaceInput; + repoRoot: string; + worktreePath: string; + branchName: string; + issue: ExecutionWorkspaceIssueRef | null; + agent: ExecutionWorkspaceAgentRef; + created: boolean; +}) { + const provisionCommand = asString(input.strategy.provisionCommand, "").trim(); + if (!provisionCommand) return; + + await runWorkspaceCommand({ + command: provisionCommand, + cwd: input.worktreePath, + env: buildWorkspaceCommandEnv({ + base: input.base, + repoRoot: input.repoRoot, + worktreePath: input.worktreePath, + branchName: input.branchName, + issue: input.issue, + agent: input.agent, + created: input.created, + }), + label: `Execution workspace provision command "${provisionCommand}"`, + }); +} + export async function realizeExecutionWorkspace(input: { base: ExecutionWorkspaceInput; config: Record; @@ -278,6 +372,16 @@ export async function realizeExecutionWorkspace(input: { if (existingWorktree) { const existingGitDir = await runGit(["rev-parse", "--git-dir"], worktreePath).catch(() => null); if (existingGitDir) { + await provisionExecutionWorktree({ + strategy: rawStrategy, + base: input.base, + repoRoot, + worktreePath, + branchName, + issue: input.issue, + agent: input.agent, + created: false, + }); return { ...input.base, strategy: "git_worktree", @@ -292,6 +396,16 @@ export async function realizeExecutionWorkspace(input: { } await runGit(["worktree", "add", "-B", branchName, worktreePath, baseRef], repoRoot); + await provisionExecutionWorktree({ + strategy: rawStrategy, + base: input.base, + repoRoot, + worktreePath, + branchName, + issue: input.issue, + agent: input.agent, + created: true, + }); return { ...input.base, diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index 731304f4..dd7e21dd 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -43,7 +43,9 @@ export type ProjectConfigFieldKey = | "execution_workspace_default_mode" | "execution_workspace_base_ref" | "execution_workspace_branch_template" - | "execution_workspace_worktree_parent_dir"; + | "execution_workspace_worktree_parent_dir" + | "execution_workspace_provision_command" + | "execution_workspace_teardown_command"; const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; @@ -885,8 +887,57 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa placeholder=".paperclip/worktrees" /> +
+
+ +
+ + commitField("execution_workspace_provision_command", { + ...updateExecutionWorkspacePolicy({ + workspaceStrategy: { + ...executionWorkspaceStrategy, + type: "git_worktree", + provisionCommand: value || null, + }, + })!, + })} + immediate + className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none" + placeholder="bash ./scripts/provision-worktree.sh" + /> +
+
+
+ +
+ + commitField("execution_workspace_teardown_command", { + ...updateExecutionWorkspacePolicy({ + workspaceStrategy: { + ...executionWorkspaceStrategy, + type: "git_worktree", + teardownCommand: value || null, + }, + })!, + })} + immediate + className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none" + placeholder="bash ./scripts/teardown-worktree.sh" + /> +

- Runtime services stay under Paperclip control and are not configured here yet. + Provision runs inside the derived worktree before agent execution. Teardown is stored here for + future cleanup flows.

)}