Add command-based worktree provisioning

This commit is contained in:
Dotta
2026-03-10 12:42:36 -05:00
parent e94ce47ba5
commit dfbb4f1ccb
11 changed files with 330 additions and 2 deletions

View File

@@ -168,6 +168,8 @@ paperclipai worktree init --from-data-dir ~/.paperclip
paperclipai worktree init --force 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 ## Quick Health Checks
In another terminal: In another terminal:

View File

@@ -7,6 +7,8 @@ export interface ExecutionWorkspaceStrategy {
baseRef?: string | null; baseRef?: string | null;
branchTemplate?: string | null; branchTemplate?: string | null;
worktreeParentDir?: string | null; worktreeParentDir?: string | null;
provisionCommand?: string | null;
teardownCommand?: string | null;
} }
export interface ProjectExecutionWorkspacePolicy { export interface ProjectExecutionWorkspacePolicy {

View File

@@ -7,6 +7,8 @@ const executionWorkspaceStrategySchema = z
baseRef: z.string().optional().nullable(), baseRef: z.string().optional().nullable(),
branchTemplate: z.string().optional().nullable(), branchTemplate: z.string().optional().nullable(),
worktreeParentDir: z.string().optional().nullable(), worktreeParentDir: z.string().optional().nullable(),
provisionCommand: z.string().optional().nullable(),
teardownCommand: z.string().optional().nullable(),
}) })
.strict(); .strict();

View File

@@ -7,6 +7,8 @@ const executionWorkspaceStrategySchema = z
baseRef: z.string().optional().nullable(), baseRef: z.string().optional().nullable(),
branchTemplate: z.string().optional().nullable(), branchTemplate: z.string().optional().nullable(),
worktreeParentDir: z.string().optional().nullable(), worktreeParentDir: z.string().optional().nullable(),
provisionCommand: z.string().optional().nullable(),
teardownCommand: z.string().optional().nullable(),
}) })
.strict(); .strict();

24
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@playwright/test': '@playwright/test':
specifier: ^1.58.2 specifier: ^1.58.2
version: 1.58.2 version: 1.58.2
cross-env:
specifier: ^10.1.0
version: 10.1.0
esbuild: esbuild:
specifier: ^0.27.3 specifier: ^0.27.3
version: 0.27.3 version: 0.27.3
@@ -68,6 +71,9 @@ importers:
drizzle-orm: drizzle-orm:
specifier: 0.38.4 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) 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: picocolors:
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
@@ -321,6 +327,9 @@ importers:
'@types/ws': '@types/ws':
specifier: ^8.18.1 specifier: ^8.18.1
version: 8.18.1 version: 8.18.1
cross-env:
specifier: ^10.1.0
version: 10.1.0
supertest: supertest:
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.2.2 version: 7.2.2
@@ -989,6 +998,9 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@epic-web/invariant@1.0.0':
resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
'@esbuild-kit/core-utils@3.3.2': '@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.is' deprecated: 'Merged into tsx: https://tsx.is'
@@ -3424,6 +3436,11 @@ packages:
crelt@1.0.6: crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} 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: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -6741,6 +6758,8 @@ snapshots:
'@embedded-postgres/windows-x64@18.1.0-beta.16': '@embedded-postgres/windows-x64@18.1.0-beta.16':
optional: true optional: true
'@epic-web/invariant@1.0.0': {}
'@esbuild-kit/core-utils@3.3.2': '@esbuild-kit/core-utils@3.3.2':
dependencies: dependencies:
esbuild: 0.18.20 esbuild: 0.18.20
@@ -9255,6 +9274,11 @@ snapshots:
crelt@1.0.6: {} 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: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1

View File

@@ -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#^\./##'
)

View File

@@ -62,6 +62,7 @@ describe("execution workspace policy helpers", () => {
workspaceStrategy: { workspaceStrategy: {
type: "git_worktree", type: "git_worktree",
baseRef: "origin/main", baseRef: "origin/main",
provisionCommand: "bash ./scripts/provision-worktree.sh",
}, },
workspaceRuntime: { workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev" }], services: [{ name: "web", command: "pnpm dev" }],
@@ -75,6 +76,7 @@ describe("execution workspace policy helpers", () => {
expect(result.workspaceStrategy).toEqual({ expect(result.workspaceStrategy).toEqual({
type: "git_worktree", type: "git_worktree",
baseRef: "origin/main", baseRef: "origin/main",
provisionCommand: "bash ./scripts/provision-worktree.sh",
}); });
expect(result.workspaceRuntime).toEqual({ expect(result.workspaceRuntime).toEqual({
services: [{ name: "web", command: "pnpm dev" }], services: [{ name: "web", command: "pnpm dev" }],
@@ -116,6 +118,8 @@ describe("execution workspace policy helpers", () => {
workspaceStrategy: { workspaceStrategy: {
type: "git_worktree", type: "git_worktree",
worktreeParentDir: ".paperclip/worktrees", worktreeParentDir: ".paperclip/worktrees",
provisionCommand: "bash ./scripts/provision-worktree.sh",
teardownCommand: "bash ./scripts/teardown-worktree.sh",
}, },
}), }),
).toEqual({ ).toEqual({
@@ -124,6 +128,8 @@ describe("execution workspace policy helpers", () => {
workspaceStrategy: { workspaceStrategy: {
type: "git_worktree", type: "git_worktree",
worktreeParentDir: ".paperclip/worktrees", worktreeParentDir: ".paperclip/worktrees",
provisionCommand: "bash ./scripts/provision-worktree.sh",
teardownCommand: "bash ./scripts/teardown-worktree.sh",
}, },
}); });
expect( expect(

View File

@@ -125,6 +125,92 @@ describe("realizeExecutionWorkspace", () => {
expect(second.cwd).toBe(first.cwd); expect(second.cwd).toBe(first.cwd);
expect(second.branchName).toBe(first.branchName); 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", () => { describe("ensureRuntimeServicesForRun", () => {

View File

@@ -24,6 +24,8 @@ function parseExecutionWorkspaceStrategy(raw: unknown): ExecutionWorkspaceStrate
...(typeof parsed.baseRef === "string" ? { baseRef: parsed.baseRef } : {}), ...(typeof parsed.baseRef === "string" ? { baseRef: parsed.baseRef } : {}),
...(typeof parsed.branchTemplate === "string" ? { branchTemplate: parsed.branchTemplate } : {}), ...(typeof parsed.branchTemplate === "string" ? { branchTemplate: parsed.branchTemplate } : {}),
...(typeof parsed.worktreeParentDir === "string" ? { worktreeParentDir: parsed.worktreeParentDir } : {}), ...(typeof parsed.worktreeParentDir === "string" ? { worktreeParentDir: parsed.worktreeParentDir } : {}),
...(typeof parsed.provisionCommand === "string" ? { provisionCommand: parsed.provisionCommand } : {}),
...(typeof parsed.teardownCommand === "string" ? { teardownCommand: parsed.teardownCommand } : {}),
}; };
} }

View File

@@ -236,6 +236,100 @@ async function directoryExists(value: string) {
return fs.stat(value).then((stats) => stats.isDirectory()).catch(() => false); 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<string, unknown>;
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: { export async function realizeExecutionWorkspace(input: {
base: ExecutionWorkspaceInput; base: ExecutionWorkspaceInput;
config: Record<string, unknown>; config: Record<string, unknown>;
@@ -278,6 +372,16 @@ export async function realizeExecutionWorkspace(input: {
if (existingWorktree) { if (existingWorktree) {
const existingGitDir = await runGit(["rev-parse", "--git-dir"], worktreePath).catch(() => null); const existingGitDir = await runGit(["rev-parse", "--git-dir"], worktreePath).catch(() => null);
if (existingGitDir) { if (existingGitDir) {
await provisionExecutionWorktree({
strategy: rawStrategy,
base: input.base,
repoRoot,
worktreePath,
branchName,
issue: input.issue,
agent: input.agent,
created: false,
});
return { return {
...input.base, ...input.base,
strategy: "git_worktree", strategy: "git_worktree",
@@ -292,6 +396,16 @@ export async function realizeExecutionWorkspace(input: {
} }
await runGit(["worktree", "add", "-B", branchName, worktreePath, baseRef], repoRoot); 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 { return {
...input.base, ...input.base,

View File

@@ -43,7 +43,9 @@ export type ProjectConfigFieldKey =
| "execution_workspace_default_mode" | "execution_workspace_default_mode"
| "execution_workspace_base_ref" | "execution_workspace_base_ref"
| "execution_workspace_branch_template" | "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__"; const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
@@ -885,8 +887,57 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
placeholder=".paperclip/worktrees" placeholder=".paperclip/worktrees"
/> />
</div> </div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Provision command</span>
<SaveIndicator state={fieldState("execution_workspace_provision_command")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.provisionCommand ?? ""}
onCommit={(value) =>
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"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Teardown command</span>
<SaveIndicator state={fieldState("execution_workspace_teardown_command")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.teardownCommand ?? ""}
onCommit={(value) =>
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"
/>
</div>
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
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.
</p> </p>
</div> </div>
)} )}