From dcac49a4fd4739ec7177b196a869aa5d1cc169d9 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:37:10 -0500 Subject: [PATCH] feat(workspaces): defer isolated setup until runtime start (#10653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Isolated workspaces give each task a safe and reproducible checkout. > - The existing setup cloned the development database before an agent needed to run the app. > - This made worktree creation slower and heavier for tasks that never start a service. > - Runtime services already use one server start path for heartbeat, operator, and startup recovery flows. > - This pull request moves heavy setup to that start path and keeps worktree creation lean. > - The benefit is faster isolated workspace creation with the same reliable runtime setup when a service starts. ## Linked Issues or Issue Description Related pull request: #10652 covers the initial deferred database-seeding slice. This pull request supersedes it with end-to-end runtime provisioning and safe cleanup. **What existing behavior does this improve?** This improves isolated worktree creation, runtime service startup, and isolated instance cleanup. **Subsystem affected** Cross-cutting: CLI worktree setup, server runtime orchestration, shared workspace contracts, and development scripts. **Current behavior** Paperclip seeds an isolated development database during worktree creation. It can also leave an isolated instance directory after workspace teardown. This work happens even when no runtime service starts. **Proposed behavior** Paperclip creates the worktree with a lean eager setup. It runs an idempotent runtime provision command before the first managed service spawn. Concurrent starts share one provision attempt. Teardown removes the isolated instance safely. **Reason and benefit** Many agent tasks only edit and test code. They do not need a running Paperclip instance. Deferring the database seed reduces workspace startup cost while preserving automatic setup for tasks that start the app. **Breaking changes** None. The new runtime provision command is optional. Existing workspace behavior is unchanged when it is absent. ## What Changed - Split Paperclip worktree setup into a lean eager script and an idempotent runtime provision script. - Added `runtimeProvisionCommand` to project, issue, realized workspace, and persisted workspace contracts. - Added a per-workspace provision mutex before local service spawn for heartbeat, operator, and startup recovery flows. - Added a persisted `provisioning` service state and the `workspace_runtime_provision` operation phase. - Kept provision time outside the service readiness timeout and made failed attempts visible and retryable. - Reclaimed isolated instance data during safe workspace teardown. - Serialized deferred database seeding across processes and bound teardown to the instance root captured in persisted workspace metadata. - Added tests for config flow, concurrency, retry, no-op behavior, readiness timing, scripts, CLI commands, and cleanup. - Documented the eager and runtime provisioning contracts. ## Verification - `pnpm -r typecheck` - `pnpm build` - `pnpm test:run` (server: 3,201 passed; UI: 3,345 passed; the CLI phase exposed one environment-sensitive AWS doctor assertion because the agent runtime injects static AWS credentials) - `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm exec vitest run cli/src/__tests__/secrets.test.ts -t 'passes AWS doctor checks when non-secret provider config is present'` - Focused runtime tests cover serialized provisioning, retry after stderr failure, absent-command no-op behavior, operation logging, persisted state order, and readiness timeout exclusion. - Focused CLI and cleanup tests cover concurrent seed serialization, stale-lock fail-closed behavior, persisted instance ownership, and rewritten sibling pointers. ## Risks - A faulty runtime provision script blocks service startup. Paperclip records stderr, marks the service failed, and retries on the next start. - Concurrent service requests share an in-process provision attempt, while the seed command uses an atomic filesystem lock across processes. A stale lock fails closed and requires an operator to verify no seed is running before removing it. - Isolated instance cleanup is destructive. The cleanup service validates ownership and path containment before removal. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, `gpt-5.6-sol`, with agentic reasoning, tool use, and code execution. The service does not expose the 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 Co-authored-by: Claude Opus 4.8 --- cli/src/__tests__/worktree.test.ts | 194 +++++++++ cli/src/commands/run.ts | 6 + cli/src/commands/worktree-lib.ts | 18 + cli/src/commands/worktree.ts | 310 +++++++++++++++ doc/DEVELOPING.md | 27 +- ...ecution-workspaces-and-runtime-services.md | 30 +- .../shared/src/types/workspace-operation.ts | 1 + .../shared/src/types/workspace-runtime.ts | 5 +- .../src/validators/execution-workspace.ts | 3 +- packages/shared/src/validators/issue.test.ts | 17 + packages/shared/src/validators/issue.ts | 1 + packages/shared/src/validators/project.ts | 1 + .../provision-worktree-self-heal.test.mjs | 90 ++++- scripts/dev-runner.ts | 8 +- scripts/provision-worktree-runtime.sh | 131 ++++++ scripts/provision-worktree.sh | 39 +- .../src/__tests__/dev-runner-worktree.test.ts | 12 + .../execution-workspace-policy.test.ts | 4 + .../execution-workspaces-service.test.ts | 4 + .../heartbeat-workspace-session.test.ts | 1 + .../issue-workspace-command-authz.test.ts | 4 +- server/src/__tests__/issues-service.test.ts | 1 + .../workspace-instance-cleanup.test.ts | 83 +++- .../workspace-runtime-routes-authz.test.ts | 1 + .../src/__tests__/workspace-runtime.test.ts | 243 +++++++++++- server/src/dev-runner-worktree.ts | 6 + server/src/routes/execution-workspaces.ts | 13 +- server/src/routes/issues.ts | 5 +- server/src/routes/projects.ts | 4 +- server/src/routes/workspace-command-authz.ts | 6 + .../services/execution-workspace-policy.ts | 3 + server/src/services/execution-workspaces.ts | 7 + server/src/services/heartbeat.ts | 60 ++- server/src/services/issues.ts | 4 + .../services/workspace-instance-cleanup.ts | 91 +++-- server/src/services/workspace-realization.ts | 2 + server/src/services/workspace-runtime.ts | 372 +++++++++++++++++- ui/src/components/ProjectProperties.tsx | 28 ++ .../WorkspaceRuntimeControls.test.tsx | 38 ++ .../components/WorkspaceRuntimeControls.tsx | 18 +- .../components/WorkspaceServiceControlBar.tsx | 5 +- ...onWorkspaceDetail.provision-status.test.ts | 92 +++++ ui/src/pages/ExecutionWorkspaceDetail.tsx | 116 +++++- ui/storybook/fixtures/paperclipData.ts | 1 + ...t-execution-workspace-strategy.stories.tsx | 58 +++ ...workspace-runtime-provisioning.stories.tsx | 93 +++++ .../workspace-service-control-bar.stories.tsx | 6 + 47 files changed, 2167 insertions(+), 95 deletions(-) create mode 100755 scripts/provision-worktree-runtime.sh create mode 100644 ui/src/pages/ExecutionWorkspaceDetail.provision-status.test.ts create mode 100644 ui/storybook/stories/project-execution-workspace-strategy.stories.tsx create mode 100644 ui/storybook/stories/workspace-runtime-provisioning.stories.tsx diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index bbd055ea1e..ce063abfd7 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -20,6 +20,8 @@ import { import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, + ensureWorktreeSeeded, + markWorktreeSeedPending, pauseSeededScheduledRoutines, quarantineSeededWorktreeExecutionState, readSourceAttachmentBody, @@ -351,6 +353,198 @@ describe("worktree helpers", () => { expect(full.nullifyColumns).toEqual({}); }); + it("ensure-seeded seeds once and fast-exits on the seed-complete marker", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-")); + try { + const sourceConfigPath = path.join(tempRoot, "source", "config.json"); + const targetRoot = path.join(tempRoot, "worktree"); + const targetConfigPath = path.join(targetRoot, ".paperclip", "config.json"); + const targetPaths = resolveWorktreeLocalPaths({ + cwd: targetRoot, + homeDir: path.join(tempRoot, "worktree-home"), + instanceId: "ensure-seeded-test", + }); + const sourceConfig = buildSourceConfig(); + const targetConfig = buildWorktreeConfig({ + sourceConfig, + paths: targetPaths, + serverPort: 3199, + databasePort: 54999, + }); + fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); + fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); + fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); + fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); + fs.writeFileSync( + path.join(targetRoot, ".paperclip", ".env"), + `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, + ); + markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath }); + + const seedDatabase = vi.fn().mockResolvedValue({ + backupSummary: "snapshot.sql", + pausedScheduledRoutines: 2, + executionQuarantine: { + disabledTimerHeartbeats: 1, + resetRunningAgents: 1, + quarantinedInProgressIssues: 1, + unassignedTodoIssues: 1, + unassignedReviewIssues: 1, + }, + reboundWorkspaces: [], + }); + + await expect( + ensureWorktreeSeeded({ config: targetConfigPath }, { seedDatabase }), + ).resolves.toMatchObject({ seeded: true, reason: "seeded" }); + await expect( + ensureWorktreeSeeded({ config: targetConfigPath }, { seedDatabase }), + ).resolves.toEqual({ seeded: false, reason: "complete_marker" }); + + expect(seedDatabase).toHaveBeenCalledTimes(1); + expect(seedDatabase).toHaveBeenCalledWith(expect.objectContaining({ + sourceConfigPath, + seedMode: "minimal", + instanceId: "ensure-seeded-test", + })); + expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-pending"))).toBe(false); + expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-complete"))).toBe(true); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("ensure-seeded keeps the pending marker when seeding fails", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-failure-")); + try { + const sourceConfigPath = path.join(tempRoot, "source", "config.json"); + const targetRoot = path.join(tempRoot, "worktree"); + const targetConfigPath = path.join(targetRoot, ".paperclip", "config.json"); + const targetPaths = resolveWorktreeLocalPaths({ + cwd: targetRoot, + homeDir: path.join(tempRoot, "worktree-home"), + instanceId: "ensure-seeded-failure", + }); + const sourceConfig = buildSourceConfig(); + const targetConfig = buildWorktreeConfig({ + sourceConfig, + paths: targetPaths, + serverPort: 3198, + databasePort: 54998, + }); + fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); + fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); + fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); + fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); + fs.writeFileSync( + path.join(targetRoot, ".paperclip", ".env"), + `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, + ); + markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath }); + + await expect( + ensureWorktreeSeeded( + { config: targetConfigPath }, + { seedDatabase: vi.fn().mockRejectedValue(new Error("seed failed")) }, + ), + ).rejects.toThrow("seed failed"); + + expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-pending"))).toBe(true); + expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-complete"))).toBe(false); + expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed.lock"))).toBe(false); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("serializes concurrent ensure-seeded calls across the seed marker lock", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-lock-")); + try { + const sourceConfigPath = path.join(tempRoot, "source", "config.json"); + const targetRoot = path.join(tempRoot, "worktree"); + const targetConfigPath = path.join(targetRoot, ".paperclip", "config.json"); + const targetPaths = resolveWorktreeLocalPaths({ + cwd: targetRoot, + homeDir: path.join(tempRoot, "worktree-home"), + instanceId: "ensure-seeded-lock", + }); + const sourceConfig = buildSourceConfig(); + const targetConfig = buildWorktreeConfig({ + sourceConfig, + paths: targetPaths, + serverPort: 3197, + databasePort: 54997, + }); + fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); + fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); + fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); + fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); + fs.writeFileSync( + path.join(targetRoot, ".paperclip", ".env"), + `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, + ); + markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath }); + + const seedDatabase = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return { + backupSummary: "snapshot.sql", + pausedScheduledRoutines: 0, + executionQuarantine: { + disabledTimerHeartbeats: 0, + resetRunningAgents: 0, + quarantinedInProgressIssues: 0, + unassignedTodoIssues: 0, + unassignedReviewIssues: 0, + }, + reboundWorkspaces: [], + }; + }); + + const results = await Promise.all([ + ensureWorktreeSeeded({ config: targetConfigPath }, { seedDatabase }), + ensureWorktreeSeeded({ config: targetConfigPath }, { seedDatabase }), + ]); + + expect(results).toEqual(expect.arrayContaining([ + expect.objectContaining({ seeded: true, reason: "seeded" }), + { seeded: false, reason: "complete_marker" }, + ])); + expect(seedDatabase).toHaveBeenCalledTimes(1); + expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed.lock"))).toBe(false); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("fails closed instead of racing to reclaim a stale seed lock", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-stale-lock-")); + try { + const targetConfigPath = path.join(tempRoot, ".paperclip", "config.json"); + const lockPath = path.join(tempRoot, ".paperclip", "seed.lock"); + fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); + fs.writeFileSync( + lockPath, + `${JSON.stringify({ + version: 1, + pid: 2_147_483_647, + token: "stale-owner", + createdAt: new Date(0).toISOString(), + })}\n`, + ); + const seedDatabase = vi.fn(); + + await expect( + ensureWorktreeSeeded({ config: targetConfigPath }, { seedDatabase }), + ).rejects.toThrow("belongs to exited process"); + + expect(seedDatabase).not.toHaveBeenCalled(); + expect(fs.existsSync(lockPath)).toBe(true); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + itEmbeddedPostgres("quarantines copied live execution state in seeded worktree databases", async () => { const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-quarantine-"); const db = createDb(tempDb.connectionString); diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index 41b02491b6..e9003e8949 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -18,6 +18,7 @@ import { } from "../config/home.js"; import { assertForegroundRunAllowed } from "../services/service-manager.js"; import { printUpdateNotice } from "../update-notice.js"; +import { ensureWorktreeSeeded } from "./worktree.js"; interface RunOptions { config?: string; @@ -67,6 +68,11 @@ export async function runCommand(opts: RunOptions): Promise { await onboard({ config: configPath, invokedByRun: true, bind: opts.bind }); } + const seedResult = await ensureWorktreeSeeded({ config: configPath }); + if (seedResult.seeded) { + p.log.success("Completed deferred worktree database seed."); + } + p.log.step("Running doctor checks..."); const summary = await doctor({ config: configPath, diff --git a/cli/src/commands/worktree-lib.ts b/cli/src/commands/worktree-lib.ts index 0d1d60f65e..7da2857c5d 100644 --- a/cli/src/commands/worktree-lib.ts +++ b/cli/src/commands/worktree-lib.ts @@ -5,6 +5,9 @@ import { expandHomePrefix } from "../config/home.js"; export const DEFAULT_WORKTREE_HOME = "~/.paperclip-worktrees"; export const WORKTREE_SEED_MODES = ["minimal", "full"] as const; +export const WORKTREE_SEED_PENDING_MARKER = "seed-pending"; +export const WORKTREE_SEED_COMPLETE_MARKER = "seed-complete"; +export const WORKTREE_SEED_LOCK_MARKER = "seed.lock"; export type WorktreeSeedMode = (typeof WORKTREE_SEED_MODES)[number]; @@ -50,6 +53,21 @@ export type WorktreeUiBranding = { color: string; }; +export type WorktreeSeedMarkerPaths = { + pending: string; + complete: string; + lock: string; +}; + +export function resolveWorktreeSeedMarkerPaths(configPath: string): WorktreeSeedMarkerPaths { + const configDir = path.dirname(path.resolve(configPath)); + return { + pending: path.resolve(configDir, WORKTREE_SEED_PENDING_MARKER), + complete: path.resolve(configDir, WORKTREE_SEED_COMPLETE_MARKER), + lock: path.resolve(configDir, WORKTREE_SEED_LOCK_MARKER), + }; +} + export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode { return (WORKTREE_SEED_MODES as readonly string[]).includes(value); } diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index ff4c6498f3..676ee7d52f 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -15,6 +15,7 @@ import { import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { createServer } from "node:net"; import { Readable } from "node:stream"; import * as p from "@clack/prompts"; @@ -64,6 +65,7 @@ import { isWorktreeSeedMode, resolveSuggestedWorktreeName, resolveWorktreeSeedPlan, + resolveWorktreeSeedMarkerPaths, resolveWorktreeLocalPaths, sanitizeWorktreeInstanceId, type WorktreeSeedPlan, @@ -147,6 +149,14 @@ type WorktreeRepairOptions = { allowLiveTarget?: boolean; }; +type WorktreeEnsureSeededOptions = { + config?: string; + fromConfig?: string; + fromDataDir?: string; + fromInstance?: string; + preserveLiveWork?: boolean; +}; + type EmbeddedPostgresInstance = { initialise(): Promise; start(): Promise; @@ -194,6 +204,29 @@ type SeedWorktreeDatabaseResult = { }>; }; +type WorktreeSeedPendingMarker = { + version: 1; + state: "pending"; + sourceConfigPath: string; + seedMode: "minimal"; + createdAt: string; +}; + +type WorktreeSeedCompleteMarker = { + version: 1; + state: "complete"; + seedMode: WorktreeSeedMode; + completedAt: string; +}; + +type SeedWorktreeDatabase = typeof seedWorktreeDatabase; + +export type EnsureWorktreeSeededResult = { + seeded: boolean; + reason: "seeded" | "complete_marker" | "legacy_unmarked"; + details?: SeedWorktreeDatabaseResult; +}; + export type SeededWorktreeExecutionQuarantineSummary = { disabledTimerHeartbeats: number; resetRunningAgents: number; @@ -1359,6 +1392,224 @@ async function seedWorktreeDatabase(input: { } } +function writeWorktreeSeedMarker( + filePath: string, + marker: WorktreeSeedPendingMarker | WorktreeSeedCompleteMarker, +): void { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 }); +} + +export function markWorktreeSeedPending(input: { + configPath: string; + sourceConfigPath: string; + now?: Date; +}): void { + const markers = resolveWorktreeSeedMarkerPaths(input.configPath); + rmSync(markers.complete, { force: true }); + writeWorktreeSeedMarker(markers.pending, { + version: 1, + state: "pending", + sourceConfigPath: path.resolve(input.sourceConfigPath), + seedMode: "minimal", + createdAt: (input.now ?? new Date()).toISOString(), + }); +} + +export function markWorktreeSeedComplete(input: { + configPath: string; + seedMode?: WorktreeSeedMode; + now?: Date; +}): void { + const markers = resolveWorktreeSeedMarkerPaths(input.configPath); + writeWorktreeSeedMarker(markers.complete, { + version: 1, + state: "complete", + seedMode: input.seedMode ?? "minimal", + completedAt: (input.now ?? new Date()).toISOString(), + }); + rmSync(markers.pending, { force: true }); +} + +function readWorktreeSeedPendingMarker(filePath: string): WorktreeSeedPendingMarker { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(filePath, "utf8")); + } catch (error) { + throw new Error( + `Invalid worktree seed-pending marker at ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if ( + !parsed + || typeof parsed !== "object" + || (parsed as { version?: unknown }).version !== 1 + || (parsed as { state?: unknown }).state !== "pending" + || typeof (parsed as { sourceConfigPath?: unknown }).sourceConfigPath !== "string" + || !(parsed as { sourceConfigPath: string }).sourceConfigPath.trim() + ) { + throw new Error(`Invalid worktree seed-pending marker at ${filePath}.`); + } + + return parsed as WorktreeSeedPendingMarker; +} + +const WORKTREE_SEED_LOCK_POLL_MS = 50; +const WORKTREE_SEED_LOCK_MALFORMED_STALE_MS = 60_000; + +type WorktreeSeedLockOwner = { + version: 1; + pid: number; + token: string; + createdAt: string; +}; + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function parseWorktreeSeedLockOwner(raw: string): WorktreeSeedLockOwner | null { + try { + const value = JSON.parse(raw) as Partial; + if ( + value.version !== 1 + || !Number.isInteger(value.pid) + || (value.pid ?? 0) <= 0 + || typeof value.token !== "string" + || !value.token + || typeof value.createdAt !== "string" + || !value.createdAt + ) { + return null; + } + return value as WorktreeSeedLockOwner; + } catch { + return null; + } +} + +async function acquireWorktreeSeedLock(lockPath: string): Promise<() => Promise> { + while (true) { + const owner: WorktreeSeedLockOwner = { + version: 1, + pid: process.pid, + token: randomUUID(), + createdAt: new Date().toISOString(), + }; + try { + const handle = await fsPromises.open(lockPath, "wx", 0o600); + try { + await handle.writeFile(`${JSON.stringify(owner)}\n`, "utf8"); + } catch (error) { + await handle.close(); + await fsPromises.rm(lockPath, { force: true }); + throw error; + } + await handle.close(); + return async () => { + const current = await fsPromises.readFile(lockPath, "utf8").catch(() => null); + if (current && parseWorktreeSeedLockOwner(current)?.token === owner.token) { + await fsPromises.rm(lockPath, { force: true }); + } + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + + const [rawOwner, lockStat] = await Promise.all([ + fsPromises.readFile(lockPath, "utf8").catch(() => null), + fsPromises.stat(lockPath).catch(() => null), + ]); + const currentOwner = rawOwner ? parseWorktreeSeedLockOwner(rawOwner) : null; + const malformedLockIsStale = Boolean( + lockStat && Date.now() - lockStat.mtimeMs >= WORKTREE_SEED_LOCK_MALFORMED_STALE_MS, + ); + if (currentOwner && !processIsAlive(currentOwner.pid)) { + throw new Error( + `Worktree seed lock ${lockPath} belongs to exited process ${currentOwner.pid}. ` + + "Verify that no seed is running, then remove the stale lock and retry.", + ); + } + if (!currentOwner && malformedLockIsStale) { + throw new Error( + `Worktree seed lock ${lockPath} is stale or malformed. ` + + "Verify that no seed is running, then remove the stale lock and retry.", + ); + } + await new Promise((resolve) => setTimeout(resolve, WORKTREE_SEED_LOCK_POLL_MS)); + } +} + +export async function ensureWorktreeSeeded( + opts: WorktreeEnsureSeededOptions = {}, + dependencies: { seedDatabase?: SeedWorktreeDatabase } = {}, +): Promise { + const configPath = resolveConfigPath(opts.config); + const markers = resolveWorktreeSeedMarkerPaths(configPath); + mkdirSync(path.dirname(markers.lock), { recursive: true }); + const releaseLock = await acquireWorktreeSeedLock(markers.lock); + try { + // These checks deliberately happen under the cross-process lock. A second + // service process waits for the first seed transaction, then observes the + // complete marker instead of cloning the same database concurrently. + if (existsSync(markers.complete)) { + return { seeded: false, reason: "complete_marker" }; + } + if (!existsSync(markers.pending)) { + // Worktrees created before lazy seeding shipped were seeded eagerly and + // have neither marker. Preserve that compatibility without re-cloning. + return { seeded: false, reason: "legacy_unmarked" }; + } + + const pending = readWorktreeSeedPendingMarker(markers.pending); + const sourceConfigPath = opts.fromConfig || opts.fromDataDir || opts.fromInstance + ? resolveSourceConfigPath({ + fromConfig: opts.fromConfig, + fromDataDir: opts.fromDataDir, + fromInstance: opts.fromInstance, + }) + : path.resolve(pending.sourceConfigPath); + + if (path.resolve(sourceConfigPath) === path.resolve(configPath)) { + throw new Error( + "Source and target Paperclip configs are the same. Pass --from-config for the source instance.", + ); + } + + const sourceConfig = readConfig(sourceConfigPath); + if (!sourceConfig) { + throw new Error(`Source config not found at ${sourceConfigPath}.`); + } + const targetConfig = readConfig(configPath); + if (!targetConfig) { + throw new Error(`Target config not found at ${configPath}.`); + } + + const targetRoot = path.dirname(path.dirname(configPath)); + const targetPaths = resolveWorktreeReseedTargetPaths({ configPath, rootPath: targetRoot }); + const seedDatabase = dependencies.seedDatabase ?? seedWorktreeDatabase; + const details = await seedDatabase({ + sourceConfigPath, + sourceConfig, + targetConfig, + targetPaths, + instanceId: targetPaths.instanceId, + seedMode: "minimal", + preserveLiveWork: opts.preserveLiveWork, + }); + markWorktreeSeedComplete({ configPath }); + return { seeded: true, reason: "seeded", details }; + } finally { + await releaseLock(); + } +} + export function resolveWorktreeSeedBackupEngine(seedPlan: WorktreeSeedPlan): "auto" | "javascript" { return seedPlan.excludedTables.length === 0 && Object.keys(seedPlan.nullifyColumns).length === 0 ? "auto" @@ -1401,6 +1652,9 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { // checkout, and a recursive rmSync here would nuke them all. rmSync(paths.configPath, { force: true }); rmSync(paths.envPath, { force: true }); + const seedMarkers = resolveWorktreeSeedMarkerPaths(paths.configPath); + rmSync(seedMarkers.pending, { force: true }); + rmSync(seedMarkers.complete, { force: true }); rmSync(paths.instanceRoot, { recursive: true, force: true }); } @@ -1420,6 +1674,10 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { }); writeConfig(targetConfig, paths.configPath); + markWorktreeSeedPending({ + configPath: paths.configPath, + sourceConfigPath, + }); const sourceEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(sourceConfigPath)); const existingAgentJwtSecret = nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET) ?? @@ -1461,6 +1719,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { seedExecutionQuarantineSummary = seeded.executionQuarantine; pausedScheduledRoutineCount = seeded.pausedScheduledRoutines; reboundWorkspaceSummary = seeded.reboundWorkspaces; + markWorktreeSeedComplete({ configPath: paths.configPath, seedMode }); spinner.stop(`Seeded isolated worktree database (${seedMode}).`); } catch (error) { spinner.stop(pc.red("Failed to seed worktree database.")); @@ -1511,6 +1770,46 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise { + printPaperclipCliBanner(); + p.intro(pc.bgCyan(pc.black(" paperclipai worktree ensure-seeded "))); + + const markers = resolveWorktreeSeedMarkerPaths(resolveConfigPath(opts.config)); + if (existsSync(markers.complete) || !existsSync(markers.pending)) { + const result = await ensureWorktreeSeeded(opts); + const reason = result.reason === "complete_marker" + ? "Seed-complete marker already present." + : "No seed-pending marker found; treating this legacy worktree as already seeded."; + p.outro(pc.green(reason)); + return; + } + + const spinner = p.spinner(); + spinner.start("Seeding isolated worktree database from source instance (minimal)..."); + try { + const result = await ensureWorktreeSeeded(opts); + spinner.stop("Seeded isolated worktree database (minimal)."); + if (result.details) { + p.log.message(pc.dim(`Seed snapshot: ${result.details.backupSummary}`)); + p.log.message( + pc.dim( + `Seed execution quarantine: ${formatSeededWorktreeExecutionQuarantineSummary(result.details.executionQuarantine)}`, + ), + ); + p.log.message(pc.dim(`Paused scheduled routines: ${result.details.pausedScheduledRoutines}`)); + for (const rebound of result.details.reboundWorkspaces) { + p.log.message( + pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`), + ); + } + } + p.outro(pc.green("Worktree database seed complete.")); + } catch (error) { + spinner.stop(pc.red("Failed to seed worktree database.")); + throw error; + } +} + export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise { printPaperclipCliBanner(); p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make "))); @@ -3158,6 +3457,7 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise { seedMode, preserveLiveWork: opts.preserveLiveWork, }); + markWorktreeSeedComplete({ configPath: targetEndpoint.configPath, seedMode }); spinner.stop(`Reseeded ${targetEndpoint.label} (${seedMode}).`); p.log.message(pc.dim(`Source: ${source.configPath}`)); p.log.message(pc.dim(`Target: ${targetEndpoint.configPath}`)); @@ -3319,6 +3619,16 @@ export function registerWorktreeCommands(program: Command): void { .option("--json", "Print JSON instead of shell exports") .action(worktreeEnvCommand); + worktree + .command("ensure-seeded") + .description("Seed a seed-pending worktree database exactly once from its source instance") + .option("-c, --config ", "Path to the target worktree config file") + .option("--from-config ", "Source config.json to seed from (defaults to the seed-pending marker)") + .option("--from-data-dir ", "Source PAPERCLIP_HOME used when deriving the source config") + .option("--from-instance ", "Source instance id when deriving the source config") + .option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues", false) + .action(worktreeEnsureSeededCommand); + program .command("worktree:list") .description("List git worktrees visible from this repo and whether they look like Paperclip worktrees") diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 9c0576464e..c8c9d1df47 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -429,6 +429,29 @@ After `worktree init`, both the server and the CLI auto-load the repo-local `.pa `pnpm dev` now fails fast in a linked git worktree when `.paperclip/.env` is missing, instead of silently booting against the default instance/port. If that happens, run `paperclipai worktree init` in the worktree first. +### Lean worktrees and deferred seeding + +Seeding a worktree database is the heaviest part of `worktree init`. That work can be deferred so a worktree is cheap to create and only pays the seed cost the first time it is actually used — the CLI/dev-time analog of the server's lazy runtime provisioning (see the board-operator guide's "Lazy runtime provisioning" section). + +Seeding state is tracked with two marker files under the worktree's `.paperclip/` directory: + +- `seed-pending` — the isolated database has not been seeded yet (a **lean** worktree). Written by `worktree init` before any seed runs. +- `seed-complete` — the database was seeded; the pending marker is removed. + +The default `worktree init` still seeds eagerly and writes `seed-complete` immediately. A lean worktree (created without an eager seed) keeps its `seed-pending` marker until something seeds it on demand: + +- `pnpm paperclipai worktree ensure-seeded` performs the deferred seed **exactly once**. It is lock-guarded and idempotent: a present `seed-complete` marker or a missing `seed-pending` marker short-circuits it, so it is safe to call repeatedly and from concurrent processes. It reads the source instance from the `seed-pending` marker unless you pass `--from-config`. +- `paperclipai run` calls `ensureWorktreeSeeded` automatically before doctor/boot, so `run` transparently seeds a lean worktree on first launch. +- Worktrees created before lazy seeding shipped have neither marker; they are treated as already-seeded for backward compatibility (never re-cloned). + +**Seed-pending guard.** `pnpm dev` (the dev-runner) refuses to boot a worktree whose database is still `seed-pending` and points you at the fix: + +``` +[paperclip] this worktree database is seed-pending. Run `pnpm paperclipai worktree ensure-seeded` before `pnpm dev`. +``` + +This guard (`isWorktreeSeedPending` in `server/src/dev-runner-worktree.ts`) prevents `pnpm dev` from starting the app against an empty, unseeded database — run `worktree ensure-seeded` once and re-run `pnpm dev`. + Provisioned git worktrees also pause seeded routines that still have enabled schedule triggers in the isolated worktree database by default. This prevents copied daily/cron routines from firing unexpectedly inside the new workspace instance during development without disabling webhook/API-only routines. That repo-local env also sets: @@ -442,7 +465,7 @@ The server/UI use those values for worktree-specific branding such as the top ba Authenticated worktree servers also use the `PAPERCLIP_INSTANCE_ID` value to scope Better Auth cookie names. Browser cookies are shared by host rather than port, so this prevents logging into one `127.0.0.1:` worktree from replacing another worktree server's session cookie. -When Paperclip closes a server-managed git worktree, it also reclaims the isolated instance referenced by that worktree's repo-local `.paperclip/.env`. New server-managed worktrees use a collision-resistant instance id derived from the resolved absolute worktree path. Cleanup requires that exact id, stops a running embedded PostgreSQL process, and then removes the instance directory. The deletion guard only accepts canonical instance paths below `PAPERCLIP_WORKTREES_DIR/instances/`; legacy or mismatched ids, pointers to the default/live Paperclip home, and all other locations are logged and left untouched. +When Paperclip closes a server-managed git worktree, it also reclaims the isolated instance referenced by that worktree's repo-local `.paperclip/.env`. New server-managed worktrees use a collision-resistant instance id derived from the resolved absolute worktree path, and Paperclip persists the resulting instance root as execution-workspace ownership metadata. Cleanup requires the env pointer to match that persisted root, stops a running embedded PostgreSQL process, and then removes the instance directory. The deletion guard only accepts canonical instance paths below `PAPERCLIP_WORKTREES_DIR/instances/`; legacy or mismatched ownership, pointers to the default/live Paperclip home, and all other locations are logged and left untouched. Print shell exports explicitly when needed: @@ -592,6 +615,8 @@ eval "$(pnpm paperclipai worktree env)" 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. +Heavier setup that is only needed by a managed runtime service can use `workspaceStrategy.runtimeProvisionCommand`. Paperclip runs this command lazily before spawning the first service in a start batch, serializes concurrent provisioning for the same workspace, and records the attempt as `workspace_runtime_provision`. The command receives the same workspace environment as `provisionCommand` and should be idempotent because later service-start batches invoke it again. + ## App-Shipped Skills Catalog The Paperclip app ships a curated catalog of company skills out of the box. The diff --git a/docs/guides/board-operator/execution-workspaces-and-runtime-services.md b/docs/guides/board-operator/execution-workspaces-and-runtime-services.md index 8e3d1ddfd3..1b0394d52f 100644 --- a/docs/guides/board-operator/execution-workspaces-and-runtime-services.md +++ b/docs/guides/board-operator/execution-workspaces-and-runtime-services.md @@ -19,14 +19,15 @@ You can define how to run a project on the project workspace itself. - This is the default runtime configuration that child execution workspaces may inherit. - Defining the config does not start anything by itself. -## Manual runtime control +## Runtime control: manual and heartbeat-driven -Workspace commands are manually controlled from the UI. +Workspace commands can be controlled manually from the UI, and heartbeat runs also start services automatically. - Project workspace services are started and stopped from the project workspace UI, and project jobs can be run on demand there. - Execution workspace services are started and stopped from the execution workspace UI, and execution-workspace jobs can be run on demand there. -- Paperclip does not automatically start or stop these workspace services as part of issue execution. -- Paperclip also does not automatically restart workspace services on server boot. +- Heartbeat runs also auto-start the workspace's runtime services at the beginning of an issue run. `ensureRuntimeServicesForRun` (`server/src/services/workspace-runtime.ts`, called from `server/src/services/heartbeat.ts`) starts each service whose desired state resolves to `running` — which is the default when no explicit per-service desired state is set. A running service that matches an existing reuse key is reused rather than restarted. +- You can opt a service out of that auto-start by setting its desired state to `stopped`/`manual` in the runtime config; those services stay UI-controlled. +- Paperclip does not automatically restart workspace services on server boot — services only come back up when the next run (or a manual start) brings them up. ## Execution workspace inheritance @@ -44,7 +45,7 @@ Issues are attached to execution workspace behavior, not to automatic runtime ma - An issue may create a new execution workspace when you choose an isolated workspace mode. - An issue may reuse an existing execution workspace when you choose reuse. - Multiple issues may intentionally share one execution workspace so they can work against the same branch and running runtime services. -- Assigning or running an issue does not automatically start or stop workspace services for that workspace. +- Running an issue auto-starts the workspace's `running`-desired runtime services for the duration of the run (see "Runtime control" above); it does not stop them when the run ends unless they are ephemeral and no other run holds a lease. ## Execution workspace lifecycle @@ -56,13 +57,25 @@ Execution workspaces are durable until a human closes them. ## Resolved workspace logic during heartbeat runs -Heartbeat still resolves a workspace for the run, but that is about code location and session continuity, not runtime-service control. +Heartbeat resolves a workspace for the run (code location and session continuity) and also brings up that workspace's runtime services. 1. Heartbeat resolves a base workspace for the run. 2. Paperclip realizes the effective execution workspace, including creating or reusing a worktree when needed. 3. Paperclip persists execution-workspace metadata such as paths, refs, and provisioning settings. 4. Heartbeat passes the resolved code workspace to the agent run. -5. Workspace runtime services remain manual UI-managed controls rather than automatic heartbeat-managed services. +5. Heartbeat calls `ensureRuntimeServicesForRun` to start the workspace's `running`-desired runtime services, running the lazy runtime provision command first if one is configured and has not yet run (see "Lazy runtime provisioning" below). + +## Lazy runtime provisioning + +Some workspaces need heavy one-time setup — seeding a database, warming caches — before their runtime services can start. That work can be deferred to the first runtime-service start instead of running eagerly during workspace preparation. + +- Configure a **runtime provision command** on the project's workspace strategy (Project properties → execution workspace), or override it per execution workspace on the workspace's Configuration tab. +- When set, workspace preparation stays lean and the command runs exactly once, immediately before the first runtime-service start for that workspace. Leaving it empty keeps the legacy eager path (all setup during workspace provisioning). +- The command's outcome is recorded as a `workspace_runtime_provision` operation on the execution workspace and surfaced on the workspace detail page: + - **Deferred** — configured but not yet run (no runtime service has started yet). + - **Provisioned at <time>** — the command completed successfully. + - **Provisioning failed** — the command failed; the workspace detail links to the runtime logs for the failing operation. +- While the command runs, the runtime service shows a **Provisioning…** state before it transitions to starting/running. ## Cross-run persistence (no-remote-git contract) @@ -81,5 +94,6 @@ With the current implementation: - Project workspace command config is the fallback for execution workspace UI controls. - Execution workspace runtime overrides are stored on the execution workspace. -- Heartbeat runs do not auto-start workspace services. +- Heartbeat runs auto-start the workspace's `running`-desired runtime services (via `ensureRuntimeServicesForRun`); services set to `stopped`/`manual` stay UI-controlled. +- A configured runtime provision command runs once, lazily, before the first runtime-service start. - Server startup does not auto-restart workspace services. diff --git a/packages/shared/src/types/workspace-operation.ts b/packages/shared/src/types/workspace-operation.ts index 18a3788e02..d1ccf6622d 100644 --- a/packages/shared/src/types/workspace-operation.ts +++ b/packages/shared/src/types/workspace-operation.ts @@ -2,6 +2,7 @@ export type WorkspaceOperationPhase = | "worktree_prepare" | "workspace_config_freshness" | "workspace_provision" + | "workspace_runtime_provision" | "workspace_teardown" | "worktree_cleanup" | "workspace_finalize"; diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 42d1f29616..8512f50532 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -76,12 +76,14 @@ export interface ExecutionWorkspaceStrategy { branchTemplate?: string | null; worktreeParentDir?: string | null; provisionCommand?: string | null; + runtimeProvisionCommand?: string | null; teardownCommand?: string | null; } export interface ExecutionWorkspaceConfig { environmentId?: string | null; provisionCommand: string | null; + runtimeProvisionCommand?: string | null; teardownCommand: string | null; cleanupCommand: string | null; workspaceRuntime: Record | null; @@ -275,7 +277,7 @@ export interface WorkspaceRuntimeService { scopeType: "project_workspace" | "execution_workspace" | "run" | "agent"; scopeId: string | null; serviceName: string; - status: "starting" | "running" | "stopped" | "failed"; + status: "provisioning" | "starting" | "running" | "stopped" | "failed"; lifecycle: "shared" | "ephemeral"; reuseKey: string | null; command: string | null; @@ -345,6 +347,7 @@ export interface WorkspaceRealizationRequest { }>; runtimeOverlay: { provisionCommand: string | null; + runtimeProvisionCommand: string | null; teardownCommand: string | null; cleanupCommand: string | null; workspaceRuntime: Record | null; diff --git a/packages/shared/src/validators/execution-workspace.ts b/packages/shared/src/validators/execution-workspace.ts index 4e3e1148c5..61d365de74 100644 --- a/packages/shared/src/validators/execution-workspace.ts +++ b/packages/shared/src/validators/execution-workspace.ts @@ -32,6 +32,7 @@ export const workspaceOverviewQuerySchema = z.object({ export const executionWorkspaceConfigSchema = z.object({ environmentId: z.string().uuid().optional().nullable(), provisionCommand: z.string().optional().nullable(), + runtimeProvisionCommand: z.string().optional().nullable(), teardownCommand: z.string().optional().nullable(), cleanupCommand: z.string().optional().nullable(), workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(), @@ -101,7 +102,7 @@ export const workspaceRuntimeServiceSchema = z.object({ scopeType: z.enum(["project_workspace", "execution_workspace", "run", "agent"]), scopeId: z.string().nullable(), serviceName: z.string(), - status: z.enum(["starting", "running", "stopped", "failed"]), + status: z.enum(["provisioning", "starting", "running", "stopped", "failed"]), lifecycle: z.enum(["shared", "ephemeral"]), reuseKey: z.string().nullable(), command: z.string().nullable(), diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 57650ba0df..434792089e 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -99,6 +99,23 @@ describe("issue validators", () => { }).success).toBe(false); }); + it("accepts a lazy runtime provision command in workspace settings", () => { + const parsed = updateIssueSchema.parse({ + executionWorkspaceSettings: { + workspaceStrategy: { + type: "git_worktree", + provisionCommand: "bash ./scripts/provision-worktree.sh", + runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh", + }, + }, + }); + + expect(parsed.executionWorkspaceSettings?.workspaceStrategy).toMatchObject({ + provisionCommand: "bash ./scripts/provision-worktree.sh", + runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh", + }); + }); + it("keeps issue attribution fields create-only", () => { const created = createIssueSchema.parse({ title: "Preserve attribution input for route checks", diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 0510d45ea9..179510ac63 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -113,6 +113,7 @@ const executionWorkspaceStrategySchema = z branchTemplate: z.string().optional().nullable(), worktreeParentDir: z.string().optional().nullable(), provisionCommand: z.string().optional().nullable(), + runtimeProvisionCommand: 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 45b2d0e146..e5ba9b2c23 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -10,6 +10,7 @@ const executionWorkspaceStrategySchema = z branchTemplate: z.string().optional().nullable(), worktreeParentDir: z.string().optional().nullable(), provisionCommand: z.string().optional().nullable(), + runtimeProvisionCommand: z.string().optional().nullable(), teardownCommand: z.string().optional().nullable(), }) .strict(); diff --git a/scripts/__tests__/provision-worktree-self-heal.test.mjs b/scripts/__tests__/provision-worktree-self-heal.test.mjs index 9f44379501..606a24edc2 100644 --- a/scripts/__tests__/provision-worktree-self-heal.test.mjs +++ b/scripts/__tests__/provision-worktree-self-heal.test.mjs @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; const script = new URL("../provision-worktree.sh", import.meta.url).pathname; +const runtimeScript = new URL("../provision-worktree-runtime.sh", import.meta.url).pathname; // Keep the PATH minimal so the fallback ladder is deterministic: node must be // reachable, but a globally installed `paperclipai` must not shadow the paths @@ -34,7 +35,7 @@ test.after(() => { * initExit: exit code for `... index.ts worktree init ...`; on 0 the fake CLI * writes a marker config so tests can tell CLI init from fallback. */ -function makeBaseWorkspace({ helpExit, initExit }) { +function makeBaseWorkspace({ helpExit, initExit, ensureExit = 0 }) { const baseCwd = makeTempDir("paperclip-provision-base-"); const runnerPath = path.join(baseCwd, "cli", "node_modules", "tsx", "dist", "cli.mjs"); const entryPath = path.join(baseCwd, "cli", "src", "index.ts"); @@ -46,6 +47,7 @@ function makeBaseWorkspace({ helpExit, initExit }) { ` import fs from "node:fs"; const cliArgs = process.argv.slice(3); +fs.appendFileSync(${JSON.stringify(path.join(baseCwd, "cli-invocations.log"))}, JSON.stringify(cliArgs) + "\\n"); if (cliArgs.includes("--help")) { if (${helpExit} !== 0) console.error("ERR_MODULE_NOT_FOUND: drizzle-orm"); process.exit(${helpExit}); @@ -60,6 +62,15 @@ if (cliArgs[0] === "worktree" && cliArgs[1] === "init") { fs.writeFileSync(".paperclip/.env", "PAPERCLIP_IN_WORKTREE=true\\n"); process.exit(0); } +if (cliArgs[0] === "worktree" && cliArgs[1] === "ensure-seeded") { + if (${ensureExit} !== 0) { + console.error("fake worktree ensure-seeded failure"); + process.exit(${ensureExit}); + } + fs.rmSync(".paperclip/seed-pending", { force: true }); + fs.writeFileSync(".paperclip/seed-complete", "{}\\n"); + process.exit(0); +} process.exit(0); `, ); @@ -85,6 +96,34 @@ function runProvision(baseCwd, { pathPrefix } = {}) { return { result, worktreeCwd, worktreesHome }; } +function runRuntimeProvision(baseCwd, worktreeCwd) { + const worktreesHome = makeTempDir("paperclip-provision-runtime-home-"); + return spawnSync("bash", [runtimeScript], { + cwd: worktreeCwd, + encoding: "utf8", + env: { + PATH: testPath, + HOME: os.homedir(), + PAPERCLIP_WORKSPACE_BASE_CWD: baseCwd, + PAPERCLIP_WORKSPACE_CWD: worktreeCwd, + PAPERCLIP_WORKSPACE_BRANCH: "feature/provision-runtime-test", + PAPERCLIP_WORKTREES_DIR: worktreesHome, + PAPERCLIP_HOME: path.join(worktreesHome, "no-such-instance-home"), + }, + }); +} + +function readCliInvocations(baseCwd) { + const logPath = path.join(baseCwd, "cli-invocations.log"); + if (!fs.existsSync(logPath)) return []; + return fs + .readFileSync(logPath, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + function readWorktreeConfig(worktreeCwd) { const configPath = path.join(worktreeCwd, ".paperclip", "config.json"); assert.ok(fs.existsSync(configPath), `expected ${configPath} to exist`); @@ -98,6 +137,14 @@ test("uses the base CLI when its import graph boots", () => { assert.equal(result.status, 0, result.stderr); const config = readWorktreeConfig(worktreeCwd); assert.equal(config.$meta.source, "fake-cli"); + assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending"))); + const initInvocation = readCliInvocations(baseCwd).find( + (args) => args[0] === "worktree" && args[1] === "init", + ); + assert.ok( + initInvocation?.includes("--no-seed"), + `expected --no-seed in ${JSON.stringify(initInvocation)}`, + ); }); test("falls back to an isolated config when the base CLI cannot boot", () => { @@ -119,6 +166,7 @@ test("falls back to an isolated config when the base CLI cannot boot", () => { ); const env = fs.readFileSync(path.join(worktreeCwd, ".paperclip", ".env"), "utf8"); assert.match(env, /PAPERCLIP_IN_WORKTREE=true/); + assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending"))); }); test("repairs an unhealthy base install under the lock and then uses the CLI", (t) => { @@ -202,3 +250,43 @@ test("a failed CLI init fails provisioning instead of being masked as success", assert.match(result.stderr, /fake worktree init failure/); assert.ok(!fs.existsSync(path.join(worktreeCwd, ".paperclip", "config.json"))); }); + +test("runtime provisioning invokes ensure-seeded once and fast-exits after success", () => { + const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 }); + const worktreeCwd = makeTempDir("paperclip-provision-runtime-worktree-"); + fs.mkdirSync(path.join(worktreeCwd, ".paperclip"), { recursive: true }); + fs.writeFileSync(path.join(worktreeCwd, ".paperclip", "config.json"), "{}\n"); + fs.writeFileSync(path.join(worktreeCwd, ".paperclip", "seed-pending"), "{}\n"); + + const first = runRuntimeProvision(baseCwd, worktreeCwd); + assert.equal(first.status, 0, first.stderr); + assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-complete"))); + assert.ok(!fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending"))); + + const ensureCallsAfterFirst = readCliInvocations(baseCwd) + .filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded"); + assert.equal(ensureCallsAfterFirst.length, 1); + assert.ok(ensureCallsAfterFirst[0].includes("--config")); + assert.ok(ensureCallsAfterFirst[0].includes("--from-config")); + + const second = runRuntimeProvision(baseCwd, worktreeCwd); + assert.equal(second.status, 0, second.stderr); + assert.match(second.stderr, /already seeded; skipping/); + const ensureCallsAfterSecond = readCliInvocations(baseCwd) + .filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded"); + assert.equal(ensureCallsAfterSecond.length, 1); +}); + +test("runtime provisioning leaves seed-pending in place when ensure-seeded fails", () => { + const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0, ensureExit: 4 }); + const worktreeCwd = makeTempDir("paperclip-provision-runtime-failure-"); + fs.mkdirSync(path.join(worktreeCwd, ".paperclip"), { recursive: true }); + fs.writeFileSync(path.join(worktreeCwd, ".paperclip", "config.json"), "{}\n"); + fs.writeFileSync(path.join(worktreeCwd, ".paperclip", "seed-pending"), "{}\n"); + + const result = runRuntimeProvision(baseCwd, worktreeCwd); + assert.equal(result.status, 4, result.stderr); + assert.match(result.stderr, /fake worktree ensure-seeded failure/); + assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending"))); + assert.ok(!fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-complete"))); +}); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index ba7fcb4cda..4848a1c77c 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -8,7 +8,7 @@ import { stdin, stdout } from "node:process"; import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.ts"; import { collectWatchedSnapshot as collectDevServerWatchedSnapshot, diffSnapshots } from "./dev-runner-snapshot.mjs"; import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts"; -import { bootstrapDevRunnerWorktreeEnv } from "../server/src/dev-runner-worktree.ts"; +import { bootstrapDevRunnerWorktreeEnv, isWorktreeSeedPending } from "../server/src/dev-runner-worktree.ts"; import { findAdoptableLocalService, removeLocalServiceRegistryRecord, @@ -28,6 +28,12 @@ if (worktreeEnvBootstrap.missingEnv) { ); process.exit(1); } +if (isWorktreeSeedPending(repoRoot)) { + console.error( + "[paperclip] this worktree database is seed-pending. Run `pnpm paperclipai worktree ensure-seeded` before `pnpm dev`.", + ); + process.exit(1); +} const mode = process.argv[2] === "watch" ? "watch" : "dev"; const cliArgs = process.argv.slice(3); diff --git a/scripts/provision-worktree-runtime.sh b/scripts/provision-worktree-runtime.sh new file mode 100755 index 0000000000..539713fa09 --- /dev/null +++ b/scripts/provision-worktree-runtime.sh @@ -0,0 +1,131 @@ +#!/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}" +paperclip_home="${PAPERCLIP_HOME:-$HOME/.paperclip}" +paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}" +paperclip_dir="$worktree_cwd/.paperclip" +worktree_config_path="$paperclip_dir/config.json" +seed_pending_marker_path="$paperclip_dir/seed-pending" +seed_complete_marker_path="$paperclip_dir/seed-complete" + +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 + +if [[ -e "$seed_complete_marker_path" || ! -e "$seed_pending_marker_path" ]]; then + echo "Worktree database is already seeded; skipping runtime provisioning." >&2 + exit 0 +fi + +if [[ ! -f "$worktree_config_path" ]]; then + echo "Worktree config does not exist: $worktree_config_path" >&2 + exit 1 +fi + +source_config_path="${PAPERCLIP_CONFIG:-}" +if [[ -z "$source_config_path" && ( -e "$base_cwd/.paperclip/config.json" || -L "$base_cwd/.paperclip/config.json" ) ]]; then + source_config_path="$base_cwd/.paperclip/config.json" +fi +if [[ -z "$source_config_path" ]]; then + source_config_path="$paperclip_home/instances/$paperclip_instance_id/config.json" +fi +source_config_args=(--from-config "$source_config_path") +if [[ "$source_config_path" == "$worktree_config_path" ]]; then + # A human may invoke this after sourcing `worktree env`, which points + # PAPERCLIP_CONFIG at the target. In that case the CLI reads the original + # source config from the seed-pending marker instead. + source_config_args=() +fi + +base_cli_runner_path="$base_cwd/cli/node_modules/tsx/dist/cli.mjs" +base_cli_entry_path="$base_cwd/cli/src/index.ts" + +base_cli_files_present() { + [[ -f "$base_cli_runner_path" && -f "$base_cli_entry_path" ]] +} + +base_cli_healthy() { + base_cli_files_present || return 1 + (cd "$base_cwd" && node "$base_cli_runner_path" "$base_cli_entry_path" --help >/dev/null 2>&1) +} + +repair_base_workspace_install() { + command -v pnpm >/dev/null 2>&1 || return 1 + [[ -f "$base_cwd/package.json" && -f "$base_cwd/pnpm-lock.yaml" ]] || return 1 + echo "Base workspace CLI at $base_cli_entry_path failed its health check (typically dangling pnpm symlinks after a partial install); repairing with pnpm install in $base_cwd." >&2 + local repair_cmd=(pnpm install --prod=false --force --frozen-lockfile --config.confirmModulesPurge=false) + local repair_lock_dir="" + if command -v git >/dev/null 2>&1; then + repair_lock_dir="$(git -C "$base_cwd" rev-parse --absolute-git-dir 2>/dev/null || true)" + fi + if [[ ! -d "$repair_lock_dir" && -d "$base_cwd/.git" ]]; then + repair_lock_dir="$base_cwd/.git" + fi + if command -v flock >/dev/null 2>&1 && [[ -d "$repair_lock_dir" ]]; then + ( + cd "$base_cwd" || exit 1 + exec 9>"$repair_lock_dir/paperclip-provision-repair.lock" + flock 9 + if base_cli_healthy; then + echo "Base workspace CLI became healthy while waiting for the repair lock; skipping reinstall." >&2 + exit 0 + fi + env -u NODE_ENV CI=true "${repair_cmd[@]}" >&2 || exit 1 + base_cli_healthy + ) + else + (cd "$base_cwd" && env -u NODE_ENV CI=true "${repair_cmd[@]}" >&2 && base_cli_healthy) + fi +} + +ensure_base_cli_healthy() { + base_cli_files_present || return 1 + base_cli_healthy && return 0 + repair_base_workspace_install +} + +run_ensure_seeded() { + if ensure_base_cli_healthy; then + ( + cd "$worktree_cwd" && + node "$base_cli_runner_path" "$base_cli_entry_path" worktree ensure-seeded --config "$worktree_config_path" "${source_config_args[@]}" + ) + return + fi + + if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then + ( + cd "$worktree_cwd" && + pnpm paperclipai worktree ensure-seeded --config "$worktree_config_path" "${source_config_args[@]}" + ) + return + fi + + if command -v paperclipai >/dev/null 2>&1; then + ( + cd "$worktree_cwd" && + paperclipai worktree ensure-seeded --config "$worktree_config_path" "${source_config_args[@]}" + ) + return + fi + + return 127 +} + +if run_ensure_seeded; then + exit 0 +else + exit_code=$? + if [[ "$exit_code" -eq 127 ]]; then + echo "No usable paperclipai CLI found; cannot seed the worktree database." >&2 + fi + exit "$exit_code" +fi diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 0c1f9a301f..b5b283a134 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -8,7 +8,10 @@ paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}" paperclip_dir="$worktree_cwd/.paperclip" worktree_config_path="$paperclip_dir/config.json" worktree_env_path="$paperclip_dir/.env" +seed_pending_marker_path="$paperclip_dir/seed-pending" +seed_complete_marker_path="$paperclip_dir/seed-complete" worktree_name="${PAPERCLIP_WORKSPACE_BRANCH:-$(basename "$worktree_cwd")}" +created_worktree_config=0 worktree_instance_id="$(WORKTREE_CWD="$worktree_cwd" node <<'EOF' const crypto = require("node:crypto"); const path = require("node:path"); @@ -111,7 +114,7 @@ run_isolated_worktree_init() { if ensure_base_cli_healthy; then ( cd "$worktree_cwd" && - node "$base_cli_runner_path" "$base_cli_entry_path" worktree init --force --seed-mode minimal --name "$worktree_name" --instance "$worktree_instance_id" --from-config "$source_config_path" + node "$base_cli_runner_path" "$base_cli_entry_path" worktree init --force --no-seed --seed-mode minimal --name "$worktree_name" --instance "$worktree_instance_id" --from-config "$source_config_path" ) return fi @@ -119,7 +122,7 @@ run_isolated_worktree_init() { if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then ( cd "$worktree_cwd" && - pnpm paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --instance "$worktree_instance_id" --from-config "$source_config_path" + pnpm paperclipai worktree init --force --no-seed --seed-mode minimal --name "$worktree_name" --instance "$worktree_instance_id" --from-config "$source_config_path" ) return fi @@ -127,7 +130,7 @@ run_isolated_worktree_init() { if command -v paperclipai >/dev/null 2>&1; then ( cd "$worktree_cwd" && - paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --instance "$worktree_instance_id" --from-config "$source_config_path" + paperclipai worktree init --force --no-seed --seed-mode minimal --name "$worktree_name" --instance "$worktree_instance_id" --from-config "$source_config_path" ) return fi @@ -234,6 +237,31 @@ for (const rawValue of runtimePaths) { EOF } +write_seed_pending_marker() { + SEED_PENDING_MARKER_PATH="$seed_pending_marker_path" \ + SEED_COMPLETE_MARKER_PATH="$seed_complete_marker_path" \ + SOURCE_CONFIG_PATH="$source_config_path" \ + node <<'EOF' +const fs = require("node:fs"); +const path = require("node:path"); + +const pendingPath = process.env.SEED_PENDING_MARKER_PATH; +const completePath = process.env.SEED_COMPLETE_MARKER_PATH; +fs.rmSync(completePath, { force: true }); +fs.writeFileSync( + pendingPath, + `${JSON.stringify({ + version: 1, + state: "pending", + sourceConfigPath: path.resolve(process.env.SOURCE_CONFIG_PATH), + seedMode: "minimal", + createdAt: new Date().toISOString(), + }, null, 2)}\n`, + { mode: 0o600 }, +); +EOF +} + write_fallback_worktree_config() { WORKTREE_NAME="$worktree_name" \ BASE_CWD="$base_cwd" \ @@ -520,6 +548,11 @@ else echo "paperclipai worktree init unavailable; writing isolated fallback config without DB seeding." >&2 write_fallback_worktree_config fi + created_worktree_config=1 +fi + +if [[ "$created_worktree_config" -eq 1 && ! -e "$seed_pending_marker_path" && ! -e "$seed_complete_marker_path" ]]; then + write_seed_pending_marker fi list_base_node_modules_paths() { diff --git a/server/src/__tests__/dev-runner-worktree.test.ts b/server/src/__tests__/dev-runner-worktree.test.ts index dc326eddac..f3a29440eb 100644 --- a/server/src/__tests__/dev-runner-worktree.test.ts +++ b/server/src/__tests__/dev-runner-worktree.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { bootstrapDevRunnerWorktreeEnv, + isWorktreeSeedPending, isLinkedGitWorktreeCheckout, resolveWorktreeEnvFilePath, } from "../dev-runner-worktree.ts"; @@ -24,6 +25,17 @@ function createTempRoot(prefix: string): string { } describe("dev-runner worktree env bootstrap", () => { + it("guards seed-pending worktrees until a seed-complete marker exists", () => { + const root = createTempRoot("paperclip-dev-runner-seed-pending-"); + fs.mkdirSync(path.join(root, ".paperclip"), { recursive: true }); + fs.writeFileSync(path.join(root, ".paperclip", "seed-pending"), "{}\n", "utf8"); + + expect(isWorktreeSeedPending(root)).toBe(true); + + fs.writeFileSync(path.join(root, ".paperclip", "seed-complete"), "{}\n", "utf8"); + expect(isWorktreeSeedPending(root)).toBe(false); + }); + it("detects linked git worktrees from .git files", () => { const root = createTempRoot("paperclip-dev-runner-worktree-"); fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8"); diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts index e487181f90..195707fe14 100644 --- a/server/src/__tests__/execution-workspace-policy.test.ts +++ b/server/src/__tests__/execution-workspace-policy.test.ts @@ -186,6 +186,7 @@ describe("execution workspace policy helpers", () => { type: "git_worktree", baseRef: "origin/main", provisionCommand: "bash ./scripts/provision-worktree.sh", + runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh", }, workspaceRuntime: { services: [{ name: "web", command: "pnpm dev" }], @@ -200,6 +201,7 @@ describe("execution workspace policy helpers", () => { type: "git_worktree", baseRef: "origin/main", provisionCommand: "bash ./scripts/provision-worktree.sh", + runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh", }); expect(result.workspaceRuntime).toEqual({ services: [{ name: "web", command: "pnpm dev" }], @@ -259,6 +261,7 @@ describe("execution workspace policy helpers", () => { type: "git_worktree", worktreeParentDir: ".paperclip/worktrees", provisionCommand: "bash ./scripts/provision-worktree.sh", + runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh", teardownCommand: "bash ./scripts/teardown-worktree.sh", }, }), @@ -269,6 +272,7 @@ describe("execution workspace policy helpers", () => { type: "git_worktree", worktreeParentDir: ".paperclip/worktrees", provisionCommand: "bash ./scripts/provision-worktree.sh", + runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh", teardownCommand: "bash ./scripts/teardown-worktree.sh", }, }); diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index 230163f0db..a1565e0f08 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -44,6 +44,7 @@ describe("execution workspace config helpers", () => { config: { environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b", provisionCommand: "bash ./scripts/provision-worktree.sh", + runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh", teardownCommand: "bash ./scripts/teardown-worktree.sh", cleanupCommand: "pkill -f vite || true", workspaceRuntime: { @@ -53,6 +54,7 @@ describe("execution workspace config helpers", () => { })).toEqual({ environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b", provisionCommand: "bash ./scripts/provision-worktree.sh", + runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh", teardownCommand: "bash ./scripts/teardown-worktree.sh", cleanupCommand: "pkill -f vite || true", desiredState: null, @@ -71,6 +73,7 @@ describe("execution workspace config helpers", () => { config: { environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b", provisionCommand: "bash ./scripts/provision-worktree.sh", + runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh", cleanupCommand: "pkill -f vite || true", }, }, @@ -87,6 +90,7 @@ describe("execution workspace config helpers", () => { config: { environmentId: "6286d5a9-9ea7-42b9-98b3-18ee904c26d7", provisionCommand: "bash ./scripts/provision-worktree.sh", + runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh", teardownCommand: "bash ./scripts/teardown-worktree.sh", cleanupCommand: "pkill -f vite || true", desiredState: null, diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index b33840fee8..033bf6034d 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -1068,6 +1068,7 @@ describe("mergeExecutionWorkspaceMetadataForPersistence", () => { config: { environmentId: "env-new", provisionCommand: "bash ./scripts/provision.sh", + runtimeProvisionCommand: null, teardownCommand: null, cleanupCommand: null, desiredState: null, diff --git a/server/src/__tests__/issue-workspace-command-authz.test.ts b/server/src/__tests__/issue-workspace-command-authz.test.ts index 1240af41c3..a79a9de8c1 100644 --- a/server/src/__tests__/issue-workspace-command-authz.test.ts +++ b/server/src/__tests__/issue-workspace-command-authz.test.ts @@ -262,7 +262,7 @@ describe("issue workspace command authorization", () => { })); }); - it("rejects agent callers that create issue workspace provision commands", async () => { + it("rejects agent callers that create issue workspace runtime provision commands", async () => { const app = await createApp({ type: "agent", agentId: "agent-1", @@ -278,7 +278,7 @@ describe("issue workspace command authorization", () => { executionWorkspaceSettings: { workspaceStrategy: { type: "git_worktree", - provisionCommand: "touch /tmp/paperclip-rce", + runtimeProvisionCommand: "touch /tmp/paperclip-rce", }, }, }); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index a96c5e3d4b..6c4342ff00 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -5041,6 +5041,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { config: { environmentId: null, provisionCommand: "bash ./scripts/provision-new.sh", + runtimeProvisionCommand: null, teardownCommand: "bash ./scripts/teardown-new.sh", cleanupCommand: null, workspaceRuntime: { profile: "new" }, diff --git a/server/src/__tests__/workspace-instance-cleanup.test.ts b/server/src/__tests__/workspace-instance-cleanup.test.ts index 8bf0066038..c46b3bc272 100644 --- a/server/src/__tests__/workspace-instance-cleanup.test.ts +++ b/server/src/__tests__/workspace-instance-cleanup.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanupWorktreeInstanceArtifacts, deriveWorktreeInstanceId, + readManagedWorktreeInstanceOwnership, readWorktreeInstancePointer, stopEmbeddedPostgresIfRunning, } from "../services/workspace-instance-cleanup.js"; @@ -89,7 +90,6 @@ describe("worktree instance cleanup", () => { expect(deriveWorktreeInstanceId(plusPath)).toMatch(/^feature-cleanup-[a-f0-9]{12}$/); expect(deriveWorktreeInstanceId(dashPath)).toMatch(/^feature-cleanup-[a-f0-9]{12}$/); }); - it("removes an instance directory inside the managed worktree instances root", async () => { const worktreesDir = await makeTempRoot("paperclip-managed-worktrees-"); const workspacePath = await makeTempRoot("paperclip-cleanup-workspace-"); @@ -106,6 +106,7 @@ describe("worktree instance cleanup", () => { workspaceId: "workspace-1", workspacePath, expectedInstanceId: instanceId, + expectedInstanceRoot: instanceRoot, worktreesDir, }); @@ -113,6 +114,29 @@ describe("worktree instance cleanup", () => { await expect(fs.stat(instanceRoot)).rejects.toMatchObject({ code: "ENOENT" }); }); + it("falls back to deterministic instance ownership when persisted root metadata is absent", async () => { + const worktreesDir = await makeTempRoot("paperclip-managed-worktrees-"); + const workspacePath = await makeTempRoot("paperclip-cleanup-workspace-"); + const instanceId = deriveWorktreeInstanceId(workspacePath); + const instanceRoot = path.join(worktreesDir, "instances", instanceId); + await fs.mkdir(path.join(instanceRoot, "db"), { recursive: true }); + await fs.writeFile(path.join(instanceRoot, "marker"), "remove me", "utf8"); + await writeWorkspaceEnv(workspacePath, worktreesDir, instanceId); + + const pointer = await readWorktreeInstancePointer(workspacePath); + const result = await cleanupWorktreeInstanceArtifacts({ + pointer: pointer!, + workspaceId: "workspace-1", + workspacePath, + expectedInstanceId: instanceId, + expectedInstanceRoot: null, + worktreesDir, + }); + + expect(result).toMatchObject({ status: "removed", instanceRoot }); + await expect(fs.stat(instanceRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("refuses and logs an instance pointer outside the managed worktree root", async () => { const worktreesDir = await makeTempRoot("paperclip-managed-worktrees-"); const liveHome = await makeTempRoot("paperclip-live-home-"); @@ -131,6 +155,7 @@ describe("worktree instance cleanup", () => { workspaceId: "workspace-1", workspacePath, expectedInstanceId: "default", + expectedInstanceRoot: path.join(worktreesDir, "instances", "default"), worktreesDir, recorder, dependencies: { stopEmbeddedPostgres, removeInstanceRoot }, @@ -156,6 +181,17 @@ describe("worktree instance cleanup", () => { await expect(readWorktreeInstancePointer(workspacePath)).resolves.toBeNull(); }); + it("captures the managed instance root for persisted workspace ownership", async () => { + const worktreesDir = await makeTempRoot("paperclip-managed-worktrees-"); + const workspacePath = await makeTempRoot("paperclip-cleanup-workspace-"); + const instanceRoot = path.join(worktreesDir, "instances", "owned-instance"); + await fs.mkdir(instanceRoot, { recursive: true }); + await writeWorkspaceEnv(workspacePath, worktreesDir, "owned-instance"); + + await expect( + readManagedWorktreeInstanceOwnership(workspacePath, worktreesDir), + ).resolves.toEqual({ instanceId: "owned-instance", instanceRoot }); + }); it("stops embedded Postgres before deleting the instance root", async () => { const worktreesDir = await makeTempRoot("paperclip-managed-worktrees-"); const workspacePath = await makeTempRoot("paperclip-cleanup-workspace-"); @@ -170,6 +206,7 @@ describe("worktree instance cleanup", () => { workspaceId: "workspace-1", workspacePath, expectedInstanceId: "ordered-cleanup", + expectedInstanceRoot: instanceRoot, worktreesDir, dependencies: { stopEmbeddedPostgres: async (dataDir) => { @@ -189,6 +226,44 @@ describe("worktree instance cleanup", () => { expect(calls).toEqual(["stop", "remove"]); }); + it("refuses a pointer rewritten to another workspace's managed instance", async () => { + const worktreesDir = await makeTempRoot("paperclip-managed-worktrees-"); + const workspacePath = await makeTempRoot("paperclip-cleanup-workspace-"); + const siblingInstanceRoot = path.join(worktreesDir, "instances", "feature-sibling"); + await fs.mkdir(path.join(siblingInstanceRoot, "db"), { recursive: true }); + await fs.writeFile(path.join(siblingInstanceRoot, "marker"), "keep me", "utf8"); + await writeWorkspaceEnv(workspacePath, worktreesDir, "feature-sibling"); + const { recorder, operations } = createRecorderDouble(); + const stopEmbeddedPostgres = vi.fn(async () => false); + const removeInstanceRoot = vi.fn(async () => {}); + + const pointer = await readWorktreeInstancePointer(workspacePath); + const result = await cleanupWorktreeInstanceArtifacts({ + pointer: pointer!, + workspaceId: "workspace-1", + workspacePath, + expectedInstanceId: "feature-sibling", + expectedInstanceRoot: path.join(worktreesDir, "instances", "feature-owner"), + worktreesDir, + recorder, + dependencies: { stopEmbeddedPostgres, removeInstanceRoot }, + }); + + expect(result).toMatchObject({ status: "refused", instanceRoot: siblingInstanceRoot }); + expect((result as { warning: string }).warning).toContain("persisted instance root"); + expect(stopEmbeddedPostgres).not.toHaveBeenCalled(); + expect(removeInstanceRoot).not.toHaveBeenCalled(); + expect(await fs.readFile(path.join(siblingInstanceRoot, "marker"), "utf8")).toBe("keep me"); + expect(operations).toEqual([ + expect.objectContaining({ + status: "skipped", + metadata: expect.objectContaining({ + expectedInstanceRoot: path.join(worktreesDir, "instances", "feature-owner"), + refusalReason: "instance_root_workspace_mismatch", + }), + }), + ]); + }); it("refuses a managed-root symlink that canonically escapes the guard", async () => { const worktreesDir = await makeTempRoot("paperclip-managed-worktrees-"); const outsideRoot = await makeTempRoot("paperclip-outside-instance-"); @@ -204,6 +279,7 @@ describe("worktree instance cleanup", () => { workspaceId: "workspace-1", workspacePath, expectedInstanceId: "escaped", + expectedInstanceRoot: path.join(worktreesDir, "instances", "escaped"), worktreesDir, }); @@ -227,6 +303,7 @@ describe("worktree instance cleanup", () => { workspaceId: "workspace-1", workspacePath, expectedInstanceId: "default", + expectedInstanceRoot: path.join(worktreesDir, "instances", "default"), worktreesDir, }); @@ -234,7 +311,6 @@ describe("worktree instance cleanup", () => { expect((result as { warning: string }).warning).toContain("managed instances directory"); await expect(fs.stat(liveInstanceRoot)).resolves.toBeDefined(); }); - it("refuses an instance pointer that belongs to a sibling worktree", async () => { const worktreesDir = await makeTempRoot("paperclip-managed-worktrees-"); const workspacePath = await makeTempRoot("paperclip-cleanup-workspace-"); @@ -251,12 +327,13 @@ describe("worktree instance cleanup", () => { workspaceId: "workspace-1", workspacePath, expectedInstanceId: "owned-worktree", + expectedInstanceRoot: null, worktreesDir, dependencies: { stopEmbeddedPostgres, removeInstanceRoot }, }); expect(result).toMatchObject({ status: "refused", instanceRoot: siblingRoot }); - expect((result as { warning: string }).warning).toContain("does not match the expected workspace instance"); + expect((result as { warning: string }).warning).toContain("expected workspace instance"); expect(stopEmbeddedPostgres).not.toHaveBeenCalled(); expect(removeInstanceRoot).not.toHaveBeenCalled(); expect(await fs.readFile(path.join(siblingRoot, "marker"), "utf8")).toBe("keep me"); diff --git a/server/src/__tests__/workspace-runtime-routes-authz.test.ts b/server/src/__tests__/workspace-runtime-routes-authz.test.ts index 437821883e..e13be3d617 100644 --- a/server/src/__tests__/workspace-runtime-routes-authz.test.ts +++ b/server/src/__tests__/workspace-runtime-routes-authz.test.ts @@ -401,6 +401,7 @@ describe.sequential("workspace runtime service route authorization", () => { workspaceStrategy: { type: "git_worktree", provisionCommand: "touch /tmp/paperclip-rce", + runtimeProvisionCommand: "touch /tmp/paperclip-runtime-rce", }, }, }); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index a0fdd82118..5543fc1526 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -20,6 +20,7 @@ import { issues, projectWorkspaces, projects, + workspaceOperations, workspaceRuntimeServices, } from "@paperclipai/db"; import { eq } from "drizzle-orm"; @@ -3341,6 +3342,7 @@ describe("realizeExecutionWorkspace", () => { sourceIssueId: "issue-1", metadata: { createdByRuntime: true, + worktreeInstanceRoot: instanceRoot, }, }, projectWorkspace: { @@ -3373,6 +3375,190 @@ describe("realizeExecutionWorkspace", () => { }); describe("ensureRuntimeServicesForRun", () => { + function configureRuntimeProvisionTestHome(workspaceRoot: string, suffix: string) { + const previousPaperclipHome = process.env.PAPERCLIP_HOME; + const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID; + process.env.PAPERCLIP_HOME = workspaceRoot; + process.env.PAPERCLIP_INSTANCE_ID = `${suffix}-${randomUUID()}`; + return () => { + if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = previousPaperclipHome; + if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; + else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId; + }; + } + + function runtimeProvisionTestConfig(input: { + provisionCommand?: string; + serviceCommand?: string; + }) { + return { + ...(input.provisionCommand + ? { runtimeProvisionCommand: input.provisionCommand } + : {}), + workspaceRuntime: { + services: [ + { + name: "web", + command: + input.serviceCommand + ?? `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`, + lifecycle: "shared", + reuseScope: "execution_workspace", + stopPolicy: { type: "manual" }, + }, + ], + }, + }; + } + + function runtimeProvisionStartInput(input: { + workspace: RealizedExecutionWorkspace; + config: Record; + recorder?: WorkspaceOperationRecorder; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + }) { + return { + invocationId: randomUUID(), + actor: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + issue: { + id: "issue-1", + identifier: "PAP-16073", + title: "Lazy runtime provision", + }, + workspace: input.workspace, + executionWorkspaceId: "execution-workspace-1", + config: input.config, + adapterEnv: {}, + recorder: input.recorder, + onLog: input.onLog, + }; + } + + it("runs runtime provisioning once when service starts race for the same workspace", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-provision-race-")); + const restorePaperclipEnv = configureRuntimeProvisionTestHome(workspaceRoot, "runtime-provision-race"); + const counterPath = path.join(workspaceRoot, "runtime-provision-count.txt"); + const provisionScript = [ + "const fs = require('node:fs');", + `fs.appendFileSync(${JSON.stringify(counterPath)}, 'run\\n');`, + "setTimeout(() => {}, 250);", + ].join(" "); + const config = runtimeProvisionTestConfig({ + provisionCommand: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(provisionScript)}`, + }); + const workspace = buildWorkspace(workspaceRoot); + const { recorder, operations } = createWorkspaceOperationRecorderDouble(); + + try { + const [first, second] = await Promise.all([ + startRuntimeServicesForWorkspaceControl(runtimeProvisionStartInput({ workspace, config, recorder })), + startRuntimeServicesForWorkspaceControl(runtimeProvisionStartInput({ workspace, config, recorder })), + ]); + + expect(first).toHaveLength(1); + expect(second).toHaveLength(1); + expect((await fs.readFile(counterPath, "utf8")).trim().split("\n")).toEqual(["run"]); + expect(operations.filter((operation) => operation.phase === "workspace_runtime_provision")).toHaveLength(1); + + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-1", + workspaceCwd: workspaceRoot, + }); + await startRuntimeServicesForWorkspaceControl( + runtimeProvisionStartInput({ workspace, config, recorder }), + ); + expect((await fs.readFile(counterPath, "utf8")).trim().split("\n")).toEqual(["run", "run"]); + expect(operations.filter((operation) => operation.phase === "workspace_runtime_provision")).toHaveLength(2); + } finally { + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-1", + workspaceCwd: workspaceRoot, + }); + await fs.rm(workspaceRoot, { recursive: true, force: true }); + restorePaperclipEnv(); + } + }); + + it("logs runtime provisioning failure and retries it on the next service start", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-provision-retry-")); + const restorePaperclipEnv = configureRuntimeProvisionTestHome(workspaceRoot, "runtime-provision-retry"); + const attemptPath = path.join(workspaceRoot, "runtime-provision-attempt.txt"); + const provisionScript = [ + "const fs = require('node:fs');", + `const attemptPath = ${JSON.stringify(attemptPath)};`, + "const attempt = fs.existsSync(attemptPath) ? Number(fs.readFileSync(attemptPath, 'utf8')) : 0;", + "fs.writeFileSync(attemptPath, String(attempt + 1));", + "if (attempt === 0) { console.error('runtime seed exploded'); process.exit(7); }", + ].join(" "); + const config = runtimeProvisionTestConfig({ + provisionCommand: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(provisionScript)}`, + }); + const workspace = buildWorkspace(workspaceRoot); + const logs: Array<{ stream: string; chunk: string }> = []; + const { recorder, operations } = createWorkspaceOperationRecorderDouble(); + const onLog = async (stream: "stdout" | "stderr", chunk: string) => { + logs.push({ stream, chunk }); + }; + + try { + await expect( + startRuntimeServicesForWorkspaceControl( + runtimeProvisionStartInput({ workspace, config, recorder, onLog }), + ), + ).rejects.toThrow(/runtime seed exploded/); + + const services = await startRuntimeServicesForWorkspaceControl( + runtimeProvisionStartInput({ workspace, config, recorder, onLog }), + ); + expect(services).toHaveLength(1); + expect(await fs.readFile(attemptPath, "utf8")).toBe("2"); + expect(logs).toContainEqual(expect.objectContaining({ + stream: "stderr", + chunk: expect.stringContaining("runtime seed exploded"), + })); + expect( + operations + .filter((operation) => operation.phase === "workspace_runtime_provision") + .map((operation) => operation.result.status), + ).toEqual(["failed", "succeeded"]); + } finally { + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-1", + workspaceCwd: workspaceRoot, + }); + await fs.rm(workspaceRoot, { recursive: true, force: true }); + restorePaperclipEnv(); + } + }); + + it("does not create a runtime provision operation when the command is absent", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-provision-noop-")); + const restorePaperclipEnv = configureRuntimeProvisionTestHome(workspaceRoot, "runtime-provision-noop"); + const workspace = buildWorkspace(workspaceRoot); + const config = runtimeProvisionTestConfig({}); + const { recorder, operations } = createWorkspaceOperationRecorderDouble(); + + try { + const services = await startRuntimeServicesForWorkspaceControl( + runtimeProvisionStartInput({ workspace, config, recorder }), + ); + expect(services).toHaveLength(1); + expect(operations).toEqual([]); + } finally { + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-1", + workspaceCwd: workspaceRoot, + }); + await fs.rm(workspaceRoot, { recursive: true, force: true }); + restorePaperclipEnv(); + } + }); + it("leaves manual runtime services untouched during agent runs", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-manual-")); const workspace = buildWorkspace(workspaceRoot); @@ -5086,7 +5272,7 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () => await db.delete(companies); }); - it("commits a starting service row before waiting for slow readiness", async () => { + it("persists provisioning before starting and excludes provision time from readiness timeout", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-slow-control-")); const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-control-home-")); const previousPaperclipHome = process.env.PAPERCLIP_HOME; @@ -5099,14 +5285,21 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () => const projectWorkspaceId = randomUUID(); const issueId = randomUUID(); const executionWorkspaceId = randomUUID(); + const provisionMarkerPath = path.join(workspaceRoot, "runtime-provisioning.marker"); const markerPath = path.join(workspaceRoot, "runtime-spawned.marker"); + const provisionScript = [ + `require("node:fs").writeFileSync(${JSON.stringify(provisionMarkerPath)}, "provisioning");`, + "setTimeout(() => {}, 1200);", + ].join(" "); + const runtimeProvisionCommand = + `${JSON.stringify(process.execPath)} -e ${JSON.stringify(provisionScript)}`; const serverScript = [ `require("node:fs").writeFileSync(${JSON.stringify(markerPath)}, "spawned");`, "setTimeout(() => {", " require(\"node:http\")", " .createServer((_req, res) => { res.end(\"ok\"); })", " .listen(Number(process.env.PORT), \"127.0.0.1\");", - "}, 700);", + "}, 100);", "setInterval(() => {}, 1000);", ].join(" "); const command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(serverScript)}`; @@ -5157,10 +5350,10 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () => baseRef: "main", }); - const waitForMarker = async () => { + const waitForMarker = async (filePath: string) => { const deadline = Date.now() + 5_000; while (Date.now() < deadline) { - if (existsSync(markerPath)) return; + if (existsSync(filePath)) return; await new Promise((resolve) => setTimeout(resolve, 25)); } throw new Error("Timed out waiting for runtime service process marker"); @@ -5208,6 +5401,7 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () => }, executionWorkspaceId, config: { + runtimeProvisionCommand, workspaceRuntime: { services: [ { @@ -5217,7 +5411,7 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () => reuseScope: "execution_workspace", port: { type: "auto", envKey: "PORT" }, expose: { urlTemplate: "http://127.0.0.1:{{port}}" }, - readiness: { type: "http", intervalMs: 50, timeoutSec: 10 }, + readiness: { type: "http", intervalMs: 50, timeoutSec: 1 }, stopPolicy: { type: "manual" }, }, ], @@ -5228,7 +5422,17 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () => startPromise.catch(() => undefined); try { - await waitForMarker(); + await waitForMarker(provisionMarkerPath); + const provisioningRow = await waitForPersistedStatus("provisioning"); + expect(provisioningRow).toMatchObject({ + executionWorkspaceId, + serviceName: "web", + status: "provisioning", + providerRef: null, + }); + expect(existsSync(markerPath)).toBe(false); + + await waitForMarker(markerPath); const startingRow = await waitForPersistedStatus("starting"); expect(startingRow).toMatchObject({ companyId, @@ -5254,6 +5458,17 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () => const runningRow = await waitForPersistedStatus("running"); expect(runningRow.id).toBe(startingRow.id); await expect(fetch(services[0]!.url!)).resolves.toMatchObject({ ok: true }); + const runtimeProvisionOperations = await db + .select() + .from(workspaceOperations) + .where(eq(workspaceOperations.executionWorkspaceId, executionWorkspaceId)); + expect(runtimeProvisionOperations).toEqual([ + expect.objectContaining({ + phase: "workspace_runtime_provision", + status: "succeeded", + command: runtimeProvisionCommand, + }), + ]); } finally { await startPromise.catch(() => undefined); await stopRuntimeServicesForExecutionWorkspace({ @@ -6311,7 +6526,7 @@ describe("workspace realization request additionalSources", () => { }; } - it("round-trips additionalSources through build/read realization request", () => { + it("round-trips additionalSources and runtime provisioning through build/read realization request", () => { const workspace = buildRealizedWorkspace({ additionalWorkspaces: [ { @@ -6333,7 +6548,15 @@ describe("workspace realization request additionalSources", () => { heartbeatRunId: "run-1", requestedMode: "shared_workspace", workspace, - workspaceConfig: null, + workspaceConfig: { + provisionCommand: null, + runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh", + teardownCommand: null, + cleanupCommand: null, + workspaceRuntime: null, + desiredState: null, + serviceStates: null, + }, }); expect(request.additionalSources).toEqual([ @@ -6353,6 +6576,9 @@ describe("workspace realization request additionalSources", () => { JSON.parse(JSON.stringify(request)), ); expect(roundTripped?.additionalSources).toEqual(request.additionalSources); + expect(roundTripped?.runtimeOverlay.runtimeProvisionCommand).toBe( + "bash ./scripts/provision-runtime.sh", + ); }); it("exposes additionalSources on the realization record so targets receive the paths", () => { @@ -6464,5 +6690,6 @@ describe("workspace realization request additionalSources", () => { expect(parsed).not.toBeNull(); expect(parsed?.additionalSources).toEqual([]); + expect(parsed?.runtimeOverlay.runtimeProvisionCommand).toBeNull(); }); }); diff --git a/server/src/dev-runner-worktree.ts b/server/src/dev-runner-worktree.ts index 7779c4e21c..800aa1e315 100644 --- a/server/src/dev-runner-worktree.ts +++ b/server/src/dev-runner-worktree.ts @@ -56,6 +56,12 @@ export function resolveWorktreeEnvFilePath(rootDir: string): string { return path.resolve(rootDir, ".paperclip", ".env"); } +export function isWorktreeSeedPending(rootDir: string): boolean { + const markerDir = path.resolve(rootDir, ".paperclip"); + return existsSync(path.resolve(markerDir, "seed-pending")) + && !existsSync(path.resolve(markerDir, "seed-complete")); +} + function expandHomePrefix(value: string): string { if (value === "~") return os.homedir(); if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index a8a59130fb..2d4b592dba 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -390,9 +390,16 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P : null, workspace: availableWorkspace, executionWorkspaceId: existing.id, - config: { workspaceRuntime: effectiveRuntimeConfig }, + config: { + workspaceRuntime: effectiveRuntimeConfig, + runtimeProvisionCommand: + existing.config?.runtimeProvisionCommand + ?? projectPolicy?.workspaceStrategy?.runtimeProvisionCommand + ?? null, + }, adapterEnv: {}, onLog, + recorder, serviceIndex: selectedServiceIndex, }); runtimeServiceCount = startedServices.length; @@ -402,7 +409,9 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P const currentDesiredState: WorkspaceRuntimeDesiredState = existing.config?.desiredState - ?? ((existing.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running") + ?? ((existing.runtimeServices ?? []).some((service) => + service.status === "provisioning" || service.status === "starting" || service.status === "running" + ) ? "running" : "stopped"); const nextRuntimeState: { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 64ec351386..d60866e64d 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -4687,6 +4687,7 @@ export function issueRoutes( ? { environmentId: workspace.config.environmentId, provisionCommand: workspace.config.provisionCommand, + runtimeProvisionCommand: workspace.config.runtimeProvisionCommand, teardownCommand: workspace.config.teardownCommand, cleanupCommand: workspace.config.cleanupCommand, workspaceRuntime: workspace.config.workspaceRuntime, @@ -4696,7 +4697,9 @@ export function issueRoutes( : null, metadata: null, runtimeServices: (workspace.runtimeServices ?? []) - .filter((service) => service.status === "starting" || service.status === "running") + .filter((service) => + service.status === "provisioning" || service.status === "starting" || service.status === "running" + ) .map(compactIssueRuntimeService), createdAt: workspace.createdAt, updatedAt: workspace.updatedAt, diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 5476db84f5..50b4be2785 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -557,7 +557,9 @@ export function projectRoutes(db: Db) { const currentDesiredState: WorkspaceRuntimeDesiredState = workspace.runtimeConfig?.desiredState - ?? ((workspace.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running") + ?? ((workspace.runtimeServices ?? []).some((service) => + service.status === "provisioning" || service.status === "starting" || service.status === "running" + ) ? "running" : "stopped"); const nextRuntimeState: { diff --git a/server/src/routes/workspace-command-authz.ts b/server/src/routes/workspace-command-authz.ts index 6c56e9d923..56e6b07ed7 100644 --- a/server/src/routes/workspace-command-authz.ts +++ b/server/src/routes/workspace-command-authz.ts @@ -19,6 +19,9 @@ function collectWorkspaceStrategyCommandPaths(raw: unknown, prefix: string): str if (hasOwn(raw, "provisionCommand")) { paths.push(prefixPath(prefix, "provisionCommand")); } + if (hasOwn(raw, "runtimeProvisionCommand")) { + paths.push(prefixPath(prefix, "runtimeProvisionCommand")); + } if (hasOwn(raw, "teardownCommand")) { paths.push(prefixPath(prefix, "teardownCommand")); } @@ -31,6 +34,9 @@ function collectExecutionWorkspaceConfigCommandPaths(raw: unknown, prefix: strin if (hasOwn(raw, "provisionCommand")) { paths.push(prefixPath(prefix, "provisionCommand")); } + if (hasOwn(raw, "runtimeProvisionCommand")) { + paths.push(prefixPath(prefix, "runtimeProvisionCommand")); + } if (hasOwn(raw, "teardownCommand")) { paths.push(prefixPath(prefix, "teardownCommand")); } diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index 6c61872e7a..667d9f90fc 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -41,6 +41,9 @@ function parseExecutionWorkspaceStrategy(raw: unknown): ExecutionWorkspaceStrate ...(typeof parsed.branchTemplate === "string" ? { branchTemplate: parsed.branchTemplate } : {}), ...(typeof parsed.worktreeParentDir === "string" ? { worktreeParentDir: parsed.worktreeParentDir } : {}), ...(typeof parsed.provisionCommand === "string" ? { provisionCommand: parsed.provisionCommand } : {}), + ...(typeof parsed.runtimeProvisionCommand === "string" + ? { runtimeProvisionCommand: parsed.runtimeProvisionCommand } + : {}), ...(typeof parsed.teardownCommand === "string" ? { teardownCommand: parsed.teardownCommand } : {}), }; } diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index de8cde106c..a533cbef32 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -680,6 +680,7 @@ export function readExecutionWorkspaceConfig(metadata: Record | const config: ExecutionWorkspaceConfig = { environmentId: readNullableString(raw.environmentId), provisionCommand: readNullableString(raw.provisionCommand), + runtimeProvisionCommand: readNullableString(raw.runtimeProvisionCommand), teardownCommand: readNullableString(raw.teardownCommand), cleanupCommand: readNullableString(raw.cleanupCommand), workspaceRuntime: cloneRecord(raw.workspaceRuntime), @@ -704,6 +705,7 @@ export function mergeExecutionWorkspaceConfig( const current = readExecutionWorkspaceConfig(metadata) ?? { environmentId: null, provisionCommand: null, + runtimeProvisionCommand: null, teardownCommand: null, cleanupCommand: null, workspaceRuntime: null, @@ -719,6 +721,10 @@ export function mergeExecutionWorkspaceConfig( const nextConfig: ExecutionWorkspaceConfig = { environmentId: patch.environmentId !== undefined ? readNullableString(patch.environmentId) : current.environmentId, provisionCommand: patch.provisionCommand !== undefined ? readNullableString(patch.provisionCommand) : current.provisionCommand, + runtimeProvisionCommand: + patch.runtimeProvisionCommand !== undefined + ? readNullableString(patch.runtimeProvisionCommand) + : current.runtimeProvisionCommand, teardownCommand: patch.teardownCommand !== undefined ? readNullableString(patch.teardownCommand) : current.teardownCommand, cleanupCommand: patch.cleanupCommand !== undefined ? readNullableString(patch.cleanupCommand) : current.cleanupCommand, workspaceRuntime: patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime, @@ -740,6 +746,7 @@ export function mergeExecutionWorkspaceConfig( nextMetadata.config = { environmentId: nextConfig.environmentId, provisionCommand: nextConfig.provisionCommand, + runtimeProvisionCommand: nextConfig.runtimeProvisionCommand, teardownCommand: nextConfig.teardownCommand, cleanupCommand: nextConfig.cleanupCommand, workspaceRuntime: nextConfig.workspaceRuntime, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 21a27fde5b..317e9dab00 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -136,6 +136,10 @@ import { type RuntimeServiceRef, sanitizeRuntimeServiceBaseEnv, } from "./workspace-runtime.js"; +import { + readManagedWorktreeInstanceOwnership, + WORKTREE_INSTANCE_ROOT_METADATA_KEY, +} from "./workspace-instance-cleanup.js"; import { issueService } from "./issues.js"; import { projectService } from "./projects.js"; import { authorizationService, type AuthorizationActor } from "./authorization.js"; @@ -1201,6 +1205,8 @@ export function applyPersistedExecutionWorkspaceConfig(input: { const nextStrategy = parseObject(nextConfig.workspaceStrategy); if (input.workspaceConfig.provisionCommand === null) delete nextStrategy.provisionCommand; else nextStrategy.provisionCommand = input.workspaceConfig.provisionCommand; + if (input.workspaceConfig.runtimeProvisionCommand === null) delete nextStrategy.runtimeProvisionCommand; + else nextStrategy.runtimeProvisionCommand = input.workspaceConfig.runtimeProvisionCommand; if (input.workspaceConfig.teardownCommand === null) delete nextStrategy.teardownCommand; else nextStrategy.teardownCommand = input.workspaceConfig.teardownCommand; nextConfig.workspaceStrategy = nextStrategy; @@ -1276,6 +1282,8 @@ function buildExecutionWorkspaceConfigSnapshot( if ("workspaceStrategy" in config) { snapshot.provisionCommand = typeof strategy.provisionCommand === "string" ? strategy.provisionCommand : null; + snapshot.runtimeProvisionCommand = + typeof strategy.runtimeProvisionCommand === "string" ? strategy.runtimeProvisionCommand : null; snapshot.teardownCommand = typeof strategy.teardownCommand === "string" ? strategy.teardownCommand : null; } @@ -1317,10 +1325,14 @@ export function stripHostWorkspaceProvisionForLowTrustSandbox(input: { if (input.selectedEnvironmentDriver !== "sandbox") return input.config; const workspaceStrategy = parseObject(input.config.workspaceStrategy); - if (typeof workspaceStrategy.provisionCommand !== "string") return input.config; + if ( + typeof workspaceStrategy.provisionCommand !== "string" + && typeof workspaceStrategy.runtimeProvisionCommand !== "string" + ) return input.config; const nextWorkspaceStrategy = { ...workspaceStrategy }; delete nextWorkspaceStrategy.provisionCommand; + delete nextWorkspaceStrategy.runtimeProvisionCommand; return { ...input.config, @@ -4607,6 +4619,7 @@ function buildWorkspaceConfigCategoryValues(input: { }, lifecycleCommands: { provisionCommand: snapshot.provisionCommand ?? null, + runtimeProvisionCommand: snapshot.runtimeProvisionCommand ?? null, teardownCommand: snapshot.teardownCommand ?? null, cleanupCommand: snapshot.cleanupCommand ?? null, }, @@ -13647,6 +13660,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) ?? reusableExistingExecutionWorkspace.config?.provisionCommand ?? projectExecutionWorkspacePolicy?.workspaceStrategy?.provisionCommand ?? null, + runtimeProvisionCommand: + configSnapshot?.runtimeProvisionCommand + ?? reusableExistingExecutionWorkspace.config?.runtimeProvisionCommand + ?? projectExecutionWorkspacePolicy?.workspaceStrategy?.runtimeProvisionCommand + ?? null, }, }, issue: issueRef, @@ -13684,7 +13702,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const resolvedProjectId = executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null; const resolvedProjectWorkspaceId = issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null; let persistedExecutionWorkspace: ExecutionWorkspace | null = null; - const nextExecutionWorkspaceMetadata = mergeExecutionWorkspaceMetadataForPersistence({ + const baseExecutionWorkspaceMetadata = mergeExecutionWorkspaceMetadataForPersistence({ existingMetadata: resolvedWorkspaceReusePolicy.shouldRestoreExistingWorkspace ? reusableExistingExecutionWorkspace?.metadata ?? null : null, @@ -13699,6 +13717,38 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) baseRef: executionWorkspace.repoRef, baseRefSha: executionWorkspace.baseRefSha ?? null, }); + let persistedWorktreeInstanceRoot = + resolvedWorkspaceReusePolicy.shouldRestoreExistingWorkspace + && typeof reusableExistingExecutionWorkspace?.metadata?.[WORKTREE_INSTANCE_ROOT_METADATA_KEY] === "string" + ? reusableExistingExecutionWorkspace.metadata[WORKTREE_INSTANCE_ROOT_METADATA_KEY] + : null; + if ( + !persistedWorktreeInstanceRoot + && executionWorkspace.strategy === "git_worktree" + && executionWorkspace.worktreePath + ) { + try { + persistedWorktreeInstanceRoot = ( + await readManagedWorktreeInstanceOwnership(executionWorkspace.worktreePath) + )?.instanceRoot ?? null; + } catch (error) { + logger.warn( + { + runId: run.id, + issueId, + executionWorkspaceCwd: executionWorkspace.cwd, + error: error instanceof Error ? error.message : String(error), + }, + "Could not record managed worktree instance ownership", + ); + } + } + const nextExecutionWorkspaceMetadata = { + ...baseExecutionWorkspaceMetadata, + ...(persistedWorktreeInstanceRoot + ? { [WORKTREE_INSTANCE_ROOT_METADATA_KEY]: persistedWorktreeInstanceRoot } + : {}), + }; const pendingForwardBranchReconcile = executionWorkspace.pendingForwardBranchReconcile ?? null; const branchNameForInitialPersistence = pendingForwardBranchReconcile?.recordedBranchName ?? executionWorkspace.branchName; @@ -13761,10 +13811,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) projectId: resolvedProjectId, projectWorkspaceId: resolvedProjectWorkspaceId, sourceIssueId: issueRef?.id ?? null, - metadata: { - createdByRuntime: true, - source: executionWorkspace.source, - }, + metadata: nextExecutionWorkspaceMetadata, }, projectWorkspace: { cwd: resolvedWorkspace.cwd, @@ -14407,6 +14454,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) config: hostExecutionWorkspaceConfig, adapterEnv, onLog, + recorder: workspaceOperationRecorder, }); if (runtimeServices.length > 0) { context.paperclipRuntimeServices = runtimeServices; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index fb34306ff1..83872d6ab6 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -300,6 +300,7 @@ function buildReusedExecutionWorkspaceConfigPatchFromIssueSettings( return { environmentId: settings?.environmentId ?? null, provisionCommand: settings?.workspaceStrategy?.provisionCommand ?? null, + runtimeProvisionCommand: settings?.workspaceStrategy?.runtimeProvisionCommand ?? null, teardownCommand: settings?.workspaceStrategy?.teardownCommand ?? null, workspaceRuntime: settings?.workspaceRuntime ?? null, }; @@ -325,6 +326,9 @@ function buildPreRealizationExecutionWorkspaceSettings(raw: unknown): Record { + const pointer = await readWorktreeInstancePointer(workspacePath); + if (!pointer) return null; + const configured = resolveConfiguredInstanceRoot(pointer); + if ("warning" in configured) { + if (!configured.warning) return null; + throw new Error(configured.warning); + } + const managedInstancesDir = resolveManagedInstancesDir(worktreesDir); + if (!isStrictChildPath(configured.instanceRoot, managedInstancesDir)) { + throw new Error( + `Refusing to record worktree instance ownership for "${configured.instanceRoot}" because it is outside "${managedInstancesDir}".`, + ); + } + return configured; } export async function cleanupWorktreeInstanceArtifacts(input: { @@ -214,6 +246,7 @@ export async function cleanupWorktreeInstanceArtifacts(input: { workspaceId: string; workspacePath: string; expectedInstanceId: string; + expectedInstanceRoot: string | null; recorder?: WorkspaceOperationRecorder | null; worktreesDir?: string; dependencies?: WorktreeInstanceCleanupDependencies; @@ -221,14 +254,13 @@ export async function cleanupWorktreeInstanceArtifacts(input: { const configured = resolveConfiguredInstanceRoot(input.pointer, input.expectedInstanceId); if ("warning" in configured && !configured.warning) return { status: "not_configured" }; - const managedWorktreesDir = path.resolve( - expandHomePrefix(input.worktreesDir?.trim() || process.env.PAPERCLIP_WORKTREES_DIR?.trim() || path.join(os.homedir(), ".paperclip-worktrees")), - ); - const managedInstancesDir = path.join(managedWorktreesDir, "instances"); + const managedInstancesDir = resolveManagedInstancesDir(input.worktreesDir); + const managedWorktreesDir = path.dirname(managedInstancesDir); + const configuredInstanceRoot = configured.instanceRoot; + let warning = "warning" in configured ? configured.warning : ""; const recordRefusal = async ( - instanceRoot: string | null, - refusalWarning: string, metadata: Record, + refusalWarning = warning, ) => { if (!input.recorder) return; await input.recorder.recordOperation({ @@ -237,7 +269,7 @@ export async function cleanupWorktreeInstanceArtifacts(input: { metadata: { workspaceId: input.workspaceId, workspacePath: input.workspacePath, - instanceRoot, + instanceRoot: configuredInstanceRoot, managedInstancesDir, cleanupAction: "remove_worktree_instance", ...metadata, @@ -247,16 +279,25 @@ export async function cleanupWorktreeInstanceArtifacts(input: { }; if ("warning" in configured) { - await recordRefusal(configured.instanceRoot, configured.warning, { refusalReason: configured.refusalReason }); + await recordRefusal({ refusalReason: configured.refusalReason }, configured.warning); return { status: "refused", instanceRoot: configured.instanceRoot, warning: configured.warning }; } - const configuredInstanceRoot = configured.instanceRoot; - let warning = ""; + if (!configuredInstanceRoot || !isStrictChildPath(configuredInstanceRoot, managedInstancesDir)) { + warning ||= `Refusing to remove instance directory "${configuredInstanceRoot ?? "unknown"}" because it is outside "${managedInstancesDir}".`; + await recordRefusal({ refusalReason: "outside_managed_instances_dir" }); + return { status: "refused", instanceRoot: configuredInstanceRoot, warning }; + } - if (!isStrictChildPath(configuredInstanceRoot, managedInstancesDir)) { - warning = `Refusing to remove instance directory "${configuredInstanceRoot}" because it is outside "${managedInstancesDir}".`; - await recordRefusal(configuredInstanceRoot, warning, { refusalReason: "outside_managed_instances_dir" }); + const expectedInstanceRoot = input.expectedInstanceRoot + ? path.resolve(input.expectedInstanceRoot) + : null; + if (expectedInstanceRoot && configuredInstanceRoot !== expectedInstanceRoot) { + warning = `Refusing to remove instance directory "${configuredInstanceRoot}" because it does not match execution workspace ${input.workspaceId}'s persisted instance root "${expectedInstanceRoot}".`; + await recordRefusal({ + expectedInstanceRoot, + refusalReason: "instance_root_workspace_mismatch", + }); return { status: "refused", instanceRoot: configuredInstanceRoot, warning }; } @@ -269,19 +310,19 @@ export async function cleanupWorktreeInstanceArtifacts(input: { let canonicalInstanceRoot: string; try { [canonicalManagedWorktreesDir, canonicalManagedInstancesDir, canonicalInstanceRoot] = await Promise.all([ - fs.realpath(managedWorktreesDir, { encoding: "utf8" }), - fs.realpath(managedInstancesDir, { encoding: "utf8" }), - fs.realpath(configuredInstanceRoot, { encoding: "utf8" }), + fs.realpath(managedWorktreesDir), + fs.realpath(managedInstancesDir), + fs.realpath(configuredInstanceRoot), ]); } catch (error) { warning = `Refusing to remove instance directory "${configuredInstanceRoot}" because its canonical path could not be verified: ${error instanceof Error ? error.message : String(error)}`; - await recordRefusal(configuredInstanceRoot, warning, { refusalReason: "canonical_path_unavailable" }); + await recordRefusal({ refusalReason: "canonical_path_unavailable" }); return { status: "refused", instanceRoot: configuredInstanceRoot, warning }; } if (canonicalManagedInstancesDir !== path.join(canonicalManagedWorktreesDir, "instances")) { warning = `Refusing to remove instance directory "${configuredInstanceRoot}" because the managed instances directory resolves outside "${canonicalManagedWorktreesDir}".`; - await recordRefusal(configuredInstanceRoot, warning, { + await recordRefusal({ canonicalInstanceRoot, canonicalManagedInstancesDir, refusalReason: "managed_instances_dir_symlink", @@ -291,7 +332,7 @@ export async function cleanupWorktreeInstanceArtifacts(input: { if (!isStrictChildPath(canonicalInstanceRoot, canonicalManagedInstancesDir)) { warning = `Refusing to remove instance directory "${configuredInstanceRoot}" because its canonical path "${canonicalInstanceRoot}" is outside "${canonicalManagedInstancesDir}".`; - await recordRefusal(configuredInstanceRoot, warning, { + await recordRefusal({ canonicalInstanceRoot, canonicalManagedInstancesDir, refusalReason: "canonical_path_outside_managed_instances_dir", @@ -304,8 +345,8 @@ export async function cleanupWorktreeInstanceArtifacts(input: { const cleanup = async () => { postgresStopped = await dependencies.stopEmbeddedPostgres(path.join(canonicalInstanceRoot, "db")); const [currentManagedInstancesDir, currentInstanceRoot] = await Promise.all([ - fs.realpath(managedInstancesDir, { encoding: "utf8" }), - fs.realpath(configuredInstanceRoot, { encoding: "utf8" }), + fs.realpath(managedInstancesDir), + fs.realpath(configuredInstanceRoot), ]); if ( currentManagedInstancesDir !== canonicalManagedInstancesDir diff --git a/server/src/services/workspace-realization.ts b/server/src/services/workspace-realization.ts index 0c11f97d9d..f4938decc3 100644 --- a/server/src/services/workspace-realization.ts +++ b/server/src/services/workspace-realization.ts @@ -97,6 +97,7 @@ export function readWorkspaceRealizationRequest(value: unknown): WorkspaceRealiz additionalSources: readAdditionalSources(parsed.additionalSources), runtimeOverlay: { provisionCommand: readString(runtimeOverlay.provisionCommand), + runtimeProvisionCommand: readString(runtimeOverlay.runtimeProvisionCommand), teardownCommand: readString(runtimeOverlay.teardownCommand), cleanupCommand: readString(runtimeOverlay.cleanupCommand), workspaceRuntime: Object.keys(parseObject(runtimeOverlay.workspaceRuntime)).length > 0 @@ -152,6 +153,7 @@ export function buildWorkspaceRealizationRequest(input: { })), runtimeOverlay: { provisionCommand: input.workspaceConfig?.provisionCommand ?? null, + runtimeProvisionCommand: input.workspaceConfig?.runtimeProvisionCommand ?? null, teardownCommand: input.workspaceConfig?.teardownCommand ?? null, cleanupCommand: input.workspaceConfig?.cleanupCommand ?? null, workspaceRuntime: input.workspaceConfig?.workspaceRuntime ?? null, diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 91e20795a5..288a7d8773 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -34,7 +34,7 @@ import { touchLocalServiceRegistryRecord, writeLocalServiceRegistryRecord, } from "./local-service-supervisor.js"; -import type { WorkspaceOperationRecorder } from "./workspace-operations.js"; +import { workspaceOperationService, type WorkspaceOperationRecorder } from "./workspace-operations.js"; import { executionWorkspaceService, readExecutionWorkspaceConfig } from "./execution-workspaces.js"; import { logActivity } from "./activity-log.js"; import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; @@ -42,6 +42,7 @@ import { cleanupWorktreeInstanceArtifacts, deriveWorktreeInstanceId, readWorktreeInstancePointer, + WORKTREE_INSTANCE_ROOT_METADATA_KEY, type WorktreeInstancePointer, } from "./workspace-instance-cleanup.js"; @@ -119,7 +120,7 @@ export interface RuntimeServiceRef { executionWorkspaceId: string | null; issueId: string | null; serviceName: string; - status: "starting" | "running" | "stopped" | "failed"; + status: "provisioning" | "starting" | "running" | "stopped" | "failed"; lifecycle: "shared" | "ephemeral"; scopeType: "project_workspace" | "execution_workspace" | "run" | "agent"; scopeId: string | null; @@ -164,6 +165,7 @@ type StoppedRuntimeServiceReuseCandidate = { const runtimeServicesById = new Map(); const runtimeServicesByReuseKey = new Map(); const runtimeServiceLeasesByRun = new Map(); +const runtimeProvisionByWorkspace = new Map>(); const DEFAULT_EXECUTE_PROCESS_OUTPUT_BYTES = 256 * 1024; type ProcessOutputCapture = { @@ -184,6 +186,7 @@ export async function resetRuntimeServicesForTests() { runtimeServicesById.clear(); runtimeServicesByReuseKey.clear(); runtimeServiceLeasesByRun.clear(); + runtimeProvisionByWorkspace.clear(); } function stableStringify(value: unknown): string { @@ -2434,6 +2437,7 @@ async function runWorkspaceCommand(input: { cwd: string; env: NodeJS.ProcessEnv; label: string; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; }) { const shell = resolveShell(); const proc = await executeProcess({ @@ -2442,6 +2446,8 @@ async function runWorkspaceCommand(input: { cwd: input.cwd, env: input.env, }); + if (proc.stdout && input.onLog) await input.onLog("stdout", `[runtime-provision] ${proc.stdout}`); + if (proc.stderr && input.onLog) await input.onLog("stderr", `[runtime-provision] ${proc.stderr}`); if (proc.code === 0) return; const details = [proc.stderr.trim(), proc.stdout.trim()].filter(Boolean).join("\n"); @@ -2517,7 +2523,7 @@ async function recordGitOperation( async function recordWorkspaceCommandOperation( recorder: WorkspaceOperationRecorder | null | undefined, input: { - phase: "workspace_provision" | "workspace_teardown"; + phase: "workspace_provision" | "workspace_runtime_provision" | "workspace_teardown"; command: string; resolvedCommand?: string; cwd: string; @@ -2525,6 +2531,7 @@ async function recordWorkspaceCommandOperation( label: string; metadata?: Record | null; successMessage?: string | null; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; }, ) { if (!recorder) { @@ -2551,6 +2558,8 @@ async function recordWorkspaceCommandOperation( stdout = result.stdout; stderr = result.stderr; code = result.code; + if (result.stdout && input.onLog) await input.onLog("stdout", `[runtime-provision] ${result.stdout}`); + if (result.stderr && input.onLog) await input.onLog("stderr", `[runtime-provision] ${result.stderr}`); return { status: result.code === 0 ? "succeeded" : "failed", exitCode: result.code, @@ -2944,6 +2953,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: { metadata?: Record | null; config?: { provisionCommand?: string | null; + runtimeProvisionCommand?: string | null; } | null; }; issue: ExecutionWorkspaceIssueRef | null; @@ -3254,6 +3264,10 @@ export async function cleanupExecutionWorkspaceArtifacts(input: { workspaceId: input.workspace.id, workspacePath, expectedInstanceId: expectedWorktreeInstanceId, + expectedInstanceRoot: + typeof input.workspace.metadata?.[WORKTREE_INSTANCE_ROOT_METADATA_KEY] === "string" + ? input.workspace.metadata[WORKTREE_INSTANCE_ROOT_METADATA_KEY] + : null, recorder: input.recorder, }); if (result.status === "refused") warnings.push(result.warning); @@ -3898,11 +3912,139 @@ type StartLocalRuntimeServiceInput = { adapterEnv: Record; service: Record; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + runtimeProvisionCommand?: string | null; + recorder?: WorkspaceOperationRecorder | null; + provisionCoordinator?: RuntimeProvisionCoordinator; + preparedProvisioningRecord?: RuntimeServiceRecord | null; + runtimeServiceId?: string; reuseKey: string | null; scopeType: "project_workspace" | "execution_workspace" | "run" | "agent"; scopeId: string | null; }; +type RuntimeProvisionCoordinator = { + promise: Promise | null; +}; + +function createRuntimeProvisionCoordinator(): RuntimeProvisionCoordinator { + return { promise: null }; +} + +function readRuntimeProvisionCommand(config: Record) { + const workspaceStrategy = parseObject(config.workspaceStrategy); + return asString( + config.runtimeProvisionCommand, + asString(workspaceStrategy.runtimeProvisionCommand, ""), + ).trim(); +} + +function runtimeProvisionWorkspaceKey(input: StartLocalRuntimeServiceInput) { + return input.executionWorkspaceId + ? `execution-workspace:${input.executionWorkspaceId}` + : input.workspace.workspaceId + ? `project-workspace:${input.workspace.workspaceId}` + : `cwd:${path.resolve(input.workspace.cwd)}`; +} + +async function runRuntimeProvisionWithWorkspaceMutex(input: StartLocalRuntimeServiceInput) { + const command = asString(input.runtimeProvisionCommand, "").trim(); + if (!command) return; + + const workspaceKey = runtimeProvisionWorkspaceKey(input); + const existing = runtimeProvisionByWorkspace.get(workspaceKey); + if (existing) { + await existing; + return; + } + + const recorder = input.recorder ?? (input.db + ? workspaceOperationService(input.db).createRecorder({ + companyId: input.agent.companyId, + heartbeatRunId: input.startedByRunId === undefined ? input.runId : input.startedByRunId, + executionWorkspaceId: input.executionWorkspaceId ?? null, + issueId: input.issue?.id ?? null, + }) + : null); + const resolvedCommand = resolveRepoManagedWorkspaceCommand(command, input.workspace.baseCwd); + const promise = recordWorkspaceCommandOperation(recorder, { + phase: "workspace_runtime_provision", + command, + resolvedCommand, + cwd: input.workspace.cwd, + env: buildWorkspaceCommandEnv({ + base: input.workspace, + repoRoot: input.workspace.baseCwd, + worktreePath: input.workspace.cwd, + branchName: input.workspace.branchName ?? "", + issue: input.issue, + agent: input.agent, + created: input.workspace.created, + }), + label: `Runtime provision command "${command}"`, + metadata: { + executionWorkspaceId: input.executionWorkspaceId ?? null, + projectWorkspaceId: input.workspace.workspaceId, + serviceName: asString(input.service.name, "service"), + resolvedCommand: resolvedCommand === command ? null : resolvedCommand, + }, + successMessage: `Provisioned runtime dependencies for ${input.workspace.cwd}\n`, + onLog: input.onLog, + }).then(() => undefined); + + runtimeProvisionByWorkspace.set(workspaceKey, promise); + try { + await promise; + } finally { + if (runtimeProvisionByWorkspace.get(workspaceKey) === promise) { + runtimeProvisionByWorkspace.delete(workspaceKey); + } + } +} + +function createProvisioningRuntimeServiceRecord( + input: StartLocalRuntimeServiceInput, + identity: ReturnType, +): RuntimeServiceRecord { + const nowIso = new Date().toISOString(); + const id = input.runtimeServiceId ?? randomUUID(); + return { + id, + companyId: input.agent.companyId, + projectId: input.workspace.projectId, + projectWorkspaceId: input.workspace.workspaceId, + executionWorkspaceId: input.executionWorkspaceId ?? null, + issueId: input.issue?.id ?? null, + serviceName: identity.serviceName, + status: "provisioning", + lifecycle: identity.lifecycle, + scopeType: input.scopeType, + scopeId: input.scopeId, + reuseKey: input.reuseKey, + command: identity.command, + cwd: identity.serviceCwd, + port: identity.identityPort, + url: null, + provider: "local_process", + providerRef: null, + ownerAgentId: input.agent.id ?? null, + startedByRunId: input.startedByRunId === undefined ? input.runId : input.startedByRunId, + lastUsedAt: nowIso, + startedAt: nowIso, + stoppedAt: null, + stopPolicy: parseObject(input.service.stopPolicy), + healthStatus: "unknown", + reused: false, + db: input.db, + child: null, + leaseRunIds: new Set(), + idleTimer: null, + envFingerprint: identity.envFingerprint, + serviceKey: `runtime-provision:${runtimeProvisionWorkspaceKey(input)}:${id}`, + profileKind: "workspace-runtime", + processGroupId: null, + }; +} + async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): Promise { const leaseRunId = input.leaseRunId === undefined ? input.runId : input.leaseRunId; const startedByRunId = input.startedByRunId === undefined ? input.runId : input.startedByRunId; @@ -4099,7 +4241,7 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P const nowIso = new Date().toISOString(); const record: RuntimeServiceRecord = { - id: stoppedReuseCandidate?.id ?? randomUUID(), + id: input.runtimeServiceId ?? stoppedReuseCandidate?.id ?? randomUUID(), companyId: input.agent.companyId, projectId: input.workspace.projectId, projectWorkspaceId: input.workspace.workspaceId, @@ -4191,10 +4333,98 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P return { record, readiness: readinessPromise }; } -async function startLocalRuntimeService(input: StartLocalRuntimeServiceInput): Promise { - const started = await spawnLocalRuntimeService(input); - await started.readiness; - return started.record; +async function prepareRuntimeProvisioning( + input: StartLocalRuntimeServiceInput, +): Promise { + const runtimeProvisionCommand = asString(input.runtimeProvisionCommand, "").trim(); + if (!runtimeProvisionCommand) return null; + const coordinator = input.provisionCoordinator ?? createRuntimeProvisionCoordinator(); + if (coordinator.promise) { + await coordinator.promise; + return null; + } + + const identity = resolveRuntimeServiceReuseIdentity({ + service: input.service, + workspace: input.workspace, + agent: input.agent, + issue: input.issue, + adapterEnv: input.adapterEnv, + scopeType: input.scopeType, + scopeId: input.scopeId, + }); + if (!identity.command) throw new Error(`Runtime service "${identity.serviceName}" is missing command`); + const provisioningRecord = createProvisioningRuntimeServiceRecord(input, identity); + await persistRuntimeServiceRecord(input.db, provisioningRecord); + if (input.onLog) { + await input.onLog( + "stdout", + `[service:${identity.serviceName}] provisioning runtime dependencies...\n`, + ); + } + + try { + coordinator.promise = runRuntimeProvisionWithWorkspaceMutex(input); + await coordinator.promise; + provisioningRecord.status = "starting"; + provisioningRecord.lastUsedAt = new Date().toISOString(); + await persistRuntimeServiceRecord(input.db, provisioningRecord); + return provisioningRecord; + } catch (error) { + const nowIso = new Date().toISOString(); + provisioningRecord.status = "failed"; + provisioningRecord.healthStatus = "unhealthy"; + provisioningRecord.lastUsedAt = nowIso; + provisioningRecord.stoppedAt = nowIso; + await persistRuntimeServiceRecord(input.db, provisioningRecord).catch(() => undefined); + if (input.onLog) { + await input.onLog( + "stderr", + `[service:${provisioningRecord.serviceName}] runtime provisioning failed: ${error instanceof Error ? error.message : String(error)}\n`, + ); + } + throw error; + } +} + +async function startLocalRuntimeService( + input: StartLocalRuntimeServiceInput, + options?: { deferReadiness?: boolean }, +): Promise { + const runtimeProvisionCommand = asString(input.runtimeProvisionCommand, "").trim(); + const provisioningRecord = input.preparedProvisioningRecord === undefined + ? await prepareRuntimeProvisioning(input) + : input.preparedProvisioningRecord; + let started: LocalRuntimeServiceStart | null = null; + + try { + started = await spawnLocalRuntimeService({ + ...input, + runtimeServiceId: provisioningRecord?.id ?? input.runtimeServiceId, + }); + if (runtimeProvisionCommand) { + await persistRuntimeServiceRecord(input.db, started.record); + } + if (provisioningRecord && started.record.id !== provisioningRecord.id && input.db) { + await input.db + .delete(workspaceRuntimeServices) + .where(eq(workspaceRuntimeServices.id, provisioningRecord.id)); + } + if (!options?.deferReadiness) { + await started.readiness; + } + return started; + } catch (error) { + if (!started && provisioningRecord && provisioningRecord.status === "starting") { + const nowIso = new Date().toISOString(); + provisioningRecord.status = "failed"; + provisioningRecord.healthStatus = "unhealthy"; + provisioningRecord.lastUsedAt = nowIso; + provisioningRecord.stoppedAt = nowIso; + await persistRuntimeServiceRecord(input.db, provisioningRecord).catch(() => undefined); + } + throw error; + } } function scheduleIdleStop(record: RuntimeServiceRecord) { @@ -4254,7 +4484,7 @@ async function markPersistedRuntimeServicesStoppedForExecutionWorkspace(input: { .where( and( eq(workspaceRuntimeServices.executionWorkspaceId, input.executionWorkspaceId), - inArray(workspaceRuntimeServices.status, ["starting", "running"]), + inArray(workspaceRuntimeServices.status, ["provisioning", "starting", "running"]), ), ); } @@ -4383,6 +4613,7 @@ export async function ensureRuntimeServicesForRun(input: { config: Record; adapterEnv: Record; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + recorder?: WorkspaceOperationRecorder | null; }): Promise { const rawServices = selectRuntimeServiceEntries({ config: input.config, @@ -4392,6 +4623,8 @@ export async function ensureRuntimeServicesForRun(input: { }); const acquiredServiceIds: string[] = []; const refs: RuntimeServiceRef[] = []; + const runtimeProvisionCommand = readRuntimeProvisionCommand(input.config); + const provisionCoordinator = createRuntimeProvisionCoordinator(); runtimeServiceLeasesByRun.set(input.runId, acquiredServiceIds); try { @@ -4433,7 +4666,7 @@ export async function ensureRuntimeServicesForRun(input: { } } - const record = await startLocalRuntimeService({ + const started = await startLocalRuntimeService({ db: input.db, runId: input.runId, agent: input.agent, @@ -4443,10 +4676,14 @@ export async function ensureRuntimeServicesForRun(input: { adapterEnv: input.adapterEnv, service, onLog: input.onLog, + runtimeProvisionCommand, + recorder: input.recorder, + provisionCoordinator, reuseKey, scopeType, scopeId, }); + const record = started.record; registerRuntimeService(input.db, record); await persistRuntimeServiceRecord(input.db, record); acquiredServiceIds.push(record.id); @@ -4470,6 +4707,7 @@ type StartRuntimeServicesForWorkspaceControlInput = { config: Record; adapterEnv: Record; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + recorder?: WorkspaceOperationRecorder | null; serviceIndex?: number | null; respectDesiredStates?: boolean; }; @@ -4486,7 +4724,15 @@ async function startRuntimeServicesForWorkspaceControlUnlocked( invocationId: string, persistenceDb = input.db, registryDb = input.db, - options?: { deferReadiness?: boolean }, + options?: { + deferReadiness?: boolean; + runtimeProvisionCommand?: string; + provisionCoordinator?: RuntimeProvisionCoordinator; + preparedProvisioning?: { + service: Record; + record: RuntimeServiceRecord; + } | null; + }, ): Promise { const refs: RuntimeServiceRef[] = []; const pendingReadiness: LocalRuntimeServiceStart[] = []; @@ -4515,6 +4761,12 @@ async function startRuntimeServicesForWorkspaceControlUnlocked( const existingId = runtimeServicesByReuseKey.get(reuseKey); const existing = existingId ? runtimeServicesById.get(existingId) : null; if (existing && existing.status === "running") { + const prepared = options?.preparedProvisioning; + if (prepared?.service === service && prepared.record.id !== existing.id && persistenceDb) { + await persistenceDb + .delete(workspaceRuntimeServices) + .where(eq(workspaceRuntimeServices.id, prepared.record.id)); + } existing.lastUsedAt = new Date().toISOString(); existing.stoppedAt = null; clearIdleTimer(existing); @@ -4540,6 +4792,13 @@ async function startRuntimeServicesForWorkspaceControlUnlocked( adapterEnv: input.adapterEnv, service, onLog: input.onLog, + runtimeProvisionCommand: options?.runtimeProvisionCommand, + recorder: input.recorder, + provisionCoordinator: options?.provisionCoordinator, + preparedProvisioningRecord: + options?.preparedProvisioning?.service === service + ? options.preparedProvisioning.record + : undefined, reuseKey, scopeType, scopeId, @@ -4547,12 +4806,9 @@ async function startRuntimeServicesForWorkspaceControlUnlocked( // Manually controlled services are not tied to a heartbeat run lifecycle, so they do not // retain a run lease and never persist a startedByRunId foreign key. - const started = options?.deferReadiness - ? await spawnLocalRuntimeService(startInput) - : { - record: await startLocalRuntimeService(startInput), - readiness: Promise.resolve(), - }; + const started = await startLocalRuntimeService(startInput, { + deferReadiness: options?.deferReadiness, + }); registerRuntimeService(registryDb, started.record); await persistRuntimeServiceRecord(persistenceDb, started.record); refs.push(toRuntimeServiceRef(started.record)); @@ -4580,9 +4836,18 @@ export async function startRuntimeServicesForWorkspaceControl( serviceStates: readConfiguredServiceStates(input.config), }); const invocationId = input.invocationId ?? randomUUID(); + const runtimeProvisionCommand = readRuntimeProvisionCommand(input.config); + const provisionCoordinator = createRuntimeProvisionCoordinator(); if (rawServices.length === 0 || !input.db || (!input.executionWorkspaceId && !input.workspace.workspaceId)) { - const batch = await startRuntimeServicesForWorkspaceControlUnlocked(input, rawServices, invocationId); + const batch = await startRuntimeServicesForWorkspaceControlUnlocked( + input, + rawServices, + invocationId, + input.db, + input.db, + { runtimeProvisionCommand, provisionCoordinator }, + ); return batch.refs; } @@ -4591,7 +4856,58 @@ export async function startRuntimeServicesForWorkspaceControl( pendingReadiness: [], startedServiceIds: [], }; + let preparedProvisioning: { + service: Record; + record: RuntimeServiceRecord; + } | null = null; try { + if (runtimeProvisionCommand) { + for (const service of rawServices) { + const { scopeType, scopeId } = resolveServiceScopeId({ + service, + workspace: input.workspace, + executionWorkspaceId: input.executionWorkspaceId, + issue: input.issue, + runId: invocationId, + agent: input.actor, + }); + const reuseKey = resolveRuntimeServiceReuseIdentity({ + service, + workspace: input.workspace, + agent: input.actor, + issue: input.issue, + adapterEnv: input.adapterEnv, + scopeType, + scopeId, + }).reuseKey; + const existingId = reuseKey ? runtimeServicesByReuseKey.get(reuseKey) : null; + const existing = existingId ? runtimeServicesById.get(existingId) : null; + if (existing?.status === "running") continue; + + const record = await prepareRuntimeProvisioning({ + db: input.db, + runId: invocationId, + leaseRunId: null, + startedByRunId: null, + agent: input.actor, + issue: input.issue, + workspace: input.workspace, + executionWorkspaceId: input.executionWorkspaceId, + adapterEnv: input.adapterEnv, + service, + onLog: input.onLog, + runtimeProvisionCommand, + recorder: input.recorder, + provisionCoordinator, + reuseKey, + scopeType, + scopeId, + }); + if (record) preparedProvisioning = { service, record }; + break; + } + } + await input.db.transaction(async (tx) => { const txDb = tx as unknown as Db; @@ -4632,7 +4948,12 @@ export async function startRuntimeServicesForWorkspaceControl( invocationId, txDb, input.db, - { deferReadiness: true }, + { + deferReadiness: true, + runtimeProvisionCommand, + provisionCoordinator, + preparedProvisioning, + }, ); }); @@ -4654,6 +4975,14 @@ export async function startRuntimeServicesForWorkspaceControl( for (const serviceId of startBatch.startedServiceIds) { await stopRuntimeService(serviceId).catch(() => undefined); } + if (preparedProvisioning && startBatch.startedServiceIds.length === 0) { + const nowIso = new Date().toISOString(); + preparedProvisioning.record.status = "failed"; + preparedProvisioning.record.healthStatus = "unhealthy"; + preparedProvisioning.record.lastUsedAt = nowIso; + preparedProvisioning.record.stoppedAt = nowIso; + await persistRuntimeServiceRecord(input.db, preparedProvisioning.record).catch(() => undefined); + } throw error; } } @@ -4757,7 +5086,7 @@ export async function stopRuntimeServicesForProjectWorkspace(input: { : and( eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId), eq(workspaceRuntimeServices.scopeType, "project_workspace"), - inArray(workspaceRuntimeServices.status, ["starting", "running"]), + inArray(workspaceRuntimeServices.status, ["provisioning", "starting", "running"]), ), ); } @@ -4798,7 +5127,7 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) { .where( and( eq(workspaceRuntimeServices.provider, "local_process"), - inArray(workspaceRuntimeServices.status, ["starting", "running", "stopped"]), + inArray(workspaceRuntimeServices.status, ["provisioning", "starting", "running", "stopped"]), ), ); @@ -5021,6 +5350,7 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) { executionWorkspaceId: row.id, config: { workspaceRuntime: effectiveRuntimeConfig, + runtimeProvisionCommand: config.runtimeProvisionCommand, desiredState: config.desiredState, serviceStates: config.serviceStates ?? null, }, diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index c078e317db..4719af6266 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -55,6 +55,7 @@ export type ProjectConfigFieldKey = | "execution_workspace_branch_template" | "execution_workspace_worktree_parent_dir" | "execution_workspace_provision_command" + | "execution_workspace_runtime_provision_command" | "execution_workspace_teardown_command"; function SaveIndicator({ state }: { state: ProjectFieldSaveState }) { @@ -1135,6 +1136,33 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa placeholder="bash ./scripts/provision-worktree.sh" /> +
+
+ +
+ + commitField("execution_workspace_runtime_provision_command", { + ...updateExecutionWorkspacePolicy({ + workspaceStrategy: { + ...executionWorkspaceStrategy, + type: "git_worktree", + runtimeProvisionCommand: 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-runtime.sh" + /> +

+ Runs once before the first runtime-service start (heavy setup, e.g. DB seed). Leave empty to keep eager provisioning. +

+