feat(workspaces): defer isolated setup until runtime start (#10653)

## 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 <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dotta 2026-08-02 10:37:10 -05:00 committed by GitHub
parent 8540ce2973
commit dcac49a4fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 2167 additions and 95 deletions

View File

@ -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);

View File

@ -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<void> {
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,

View File

@ -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);
}

View File

@ -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<void>;
start(): Promise<void>;
@ -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<WorktreeSeedLockOwner>;
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<void>> {
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<EnsureWorktreeSeededResult> {
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<void> {
// 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<void> {
});
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<void> {
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<vo
await runWorktreeInit(opts);
}
export async function worktreeEnsureSeededCommand(opts: WorktreeEnsureSeededOptions): Promise<void> {
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<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make ")));
@ -3158,6 +3457,7 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise<void> {
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>", "Path to the target worktree config file")
.option("--from-config <path>", "Source config.json to seed from (defaults to the seed-pending marker)")
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
.option("--from-instance <id>", "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")

View File

@ -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:<port>` 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

View File

@ -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 &lt;time&gt;** — 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.

View File

@ -2,6 +2,7 @@ export type WorkspaceOperationPhase =
| "worktree_prepare"
| "workspace_config_freshness"
| "workspace_provision"
| "workspace_runtime_provision"
| "workspace_teardown"
| "worktree_cleanup"
| "workspace_finalize";

View File

@ -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<string, unknown> | 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<string, unknown> | null;

View File

@ -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(),

View File

@ -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",

View File

@ -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();

View File

@ -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();

View File

@ -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")));
});

View File

@ -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);

View File

@ -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

View File

@ -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() {

View File

@ -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");

View File

@ -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",
},
});

View File

@ -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,

View File

@ -1068,6 +1068,7 @@ describe("mergeExecutionWorkspaceMetadataForPersistence", () => {
config: {
environmentId: "env-new",
provisionCommand: "bash ./scripts/provision.sh",
runtimeProvisionCommand: null,
teardownCommand: null,
cleanupCommand: null,
desiredState: null,

View File

@ -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",
},
},
});

View File

@ -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" },

View File

@ -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");

View File

@ -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",
},
},
});

View File

@ -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<string, unknown>;
recorder?: WorkspaceOperationRecorder;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}) {
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();
});
});

View File

@ -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));

View File

@ -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: {

View File

@ -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,

View File

@ -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: {

View File

@ -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"));
}

View File

@ -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 } : {}),
};
}

View File

@ -680,6 +680,7 @@ export function readExecutionWorkspaceConfig(metadata: Record<string, unknown> |
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,

View File

@ -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;

View File

@ -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<str
...(settings.workspaceStrategy.branchTemplate ? { branchTemplate: settings.workspaceStrategy.branchTemplate } : {}),
...(settings.workspaceStrategy.worktreeParentDir ? { worktreeParentDir: settings.workspaceStrategy.worktreeParentDir } : {}),
...(settings.workspaceStrategy.provisionCommand ? { provisionCommand: settings.workspaceStrategy.provisionCommand } : {}),
...(settings.workspaceStrategy.runtimeProvisionCommand
? { runtimeProvisionCommand: settings.workspaceStrategy.runtimeProvisionCommand }
: {}),
...(settings.workspaceStrategy.teardownCommand ? { teardownCommand: settings.workspaceStrategy.teardownCommand } : {}),
};
}

View File

@ -12,6 +12,7 @@ import type { WorkspaceOperationRecorder } from "./workspace-operations.js";
const execFileAsync = promisify(execFile);
const INSTANCE_ID_RE = /^[A-Za-z0-9_-]+$/;
const POSTGRES_STOP_TIMEOUT_MS = 10_000;
export const WORKTREE_INSTANCE_ROOT_METADATA_KEY = "worktreeInstanceRoot";
export function deriveWorktreeInstanceId(workspacePath: string): string {
const resolvedWorkspacePath = path.resolve(workspacePath);
@ -173,8 +174,8 @@ export async function readWorktreeInstancePointer(workspacePath: string): Promis
}
}
function resolveConfiguredInstanceRoot(pointer: WorktreeInstancePointer, expectedInstanceId: string):
| { instanceRoot: string }
function resolveConfiguredInstanceRoot(pointer: WorktreeInstancePointer, expectedInstanceId?: string):
| { instanceRoot: string; instanceId: string }
| { warning: string; instanceRoot: string | null; refusalReason: string | null } {
const env = parseEnvContents(pointer.envContents);
const configuredHome = env.PAPERCLIP_HOME?.trim();
@ -199,14 +200,45 @@ function resolveConfiguredInstanceRoot(pointer: WorktreeInstancePointer, expecte
};
}
const instanceRoot = path.resolve(expandedHome, "instances", instanceId);
if (instanceId !== expectedInstanceId) {
if (expectedInstanceId && instanceId !== expectedInstanceId) {
return {
instanceRoot,
warning: `Refusing worktree instance cleanup from ${pointer.envPath}: PAPERCLIP_INSTANCE_ID "${instanceId}" does not match the expected workspace instance "${expectedInstanceId}".`,
refusalReason: "instance_id_mismatch",
};
}
return { instanceRoot };
return { instanceRoot, instanceId };
}
function resolveManagedInstancesDir(worktreesDir?: string): string {
const managedWorktreesDir = path.resolve(
expandHomePrefix(
worktreesDir?.trim()
|| process.env.PAPERCLIP_WORKTREES_DIR?.trim()
|| path.join(os.homedir(), ".paperclip-worktrees"),
),
);
return path.join(managedWorktreesDir, "instances");
}
export async function readManagedWorktreeInstanceOwnership(
workspacePath: string,
worktreesDir?: string,
): Promise<{ instanceRoot: string; instanceId: string } | null> {
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<string, unknown>,
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

View File

@ -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,

View File

@ -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<string, RuntimeServiceRecord>();
const runtimeServicesByReuseKey = new Map<string, string>();
const runtimeServiceLeasesByRun = new Map<string, string[]>();
const runtimeProvisionByWorkspace = new Map<string, Promise<void>>();
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<void>;
}) {
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<string, unknown> | null;
successMessage?: string | null;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
},
) {
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<string, unknown> | 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<string, string>;
service: Record<string, unknown>;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
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<void> | null;
};
function createRuntimeProvisionCoordinator(): RuntimeProvisionCoordinator {
return { promise: null };
}
function readRuntimeProvisionCommand(config: Record<string, unknown>) {
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<typeof resolveRuntimeServiceReuseIdentity>,
): 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<LocalRuntimeServiceStart> {
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<RuntimeServiceRecord> {
const started = await spawnLocalRuntimeService(input);
await started.readiness;
return started.record;
async function prepareRuntimeProvisioning(
input: StartLocalRuntimeServiceInput,
): Promise<RuntimeServiceRecord | null> {
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<LocalRuntimeServiceStart> {
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<string, unknown>;
adapterEnv: Record<string, string>;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
recorder?: WorkspaceOperationRecorder | null;
}): Promise<RuntimeServiceRef[]> {
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<string, unknown>;
adapterEnv: Record<string, string>;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
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<string, unknown>;
record: RuntimeServiceRecord;
} | null;
},
): Promise<WorkspaceControlStartBatch> {
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<string, unknown>;
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,
},

View File

@ -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"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Runtime provision command</span>
<SaveIndicator state={fieldState("execution_workspace_runtime_provision_command")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.runtimeProvisionCommand ?? ""}
onCommit={(value) =>
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"
/>
<p className="mt-1 text-xs text-muted-foreground">
Runs once before the first runtime-service start (heavy setup, e.g. DB seed). Leave empty to keep eager provisioning.
</p>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">

View File

@ -569,6 +569,44 @@ describe("buildWorkspaceServiceControlEntries", () => {
expect(entries.map((entry) => entry.state)).toEqual(["stopping", "stopping"]);
});
it("maps a provisioning runtime service to the provisioning control state", () => {
const provisioning = createRuntimeService({
id: "service-web",
serviceName: "web",
status: "provisioning",
});
const built = buildWorkspaceRuntimeControlSections({
runtimeConfig: { commands: [{ id: "web", name: "web", kind: "service", command: "pnpm dev" }] },
runtimeServices: [provisioning],
canStartServices: true,
});
expect(built.services[0]).toMatchObject({ statusLabel: "provisioning", runtimeServiceId: "service-web" });
const entries = buildWorkspaceServiceControlEntries({ sections: built, runtimeServices: [provisioning] });
expect(entries[0].state).toBe("provisioning");
});
it("surfaces a provisioning stale runtime service in otherServices", () => {
const provisioning = createRuntimeService({
id: "service-web",
serviceName: "web",
status: "provisioning",
command: "pnpm dev",
});
const built = buildWorkspaceRuntimeControlSections({
runtimeConfig: {
commands: [{ id: "web", name: "web", kind: "service", command: "pnpm dev:once --tailscale-auth" }],
},
runtimeServices: [provisioning],
canStartServices: true,
});
expect(built.otherServices).toEqual([
expect.objectContaining({ title: "web", statusLabel: "provisioning", runtimeServiceId: "service-web" }),
]);
});
it("builds a failure detail line from the stopped runtime service", () => {
const failed = createRuntimeService({
id: "service-web",

View File

@ -159,7 +159,9 @@ export function buildWorkspaceRuntimeControlSections(input: {
const otherServices = runtimeServices
.filter((runtimeService) =>
!matchedRuntimeServiceIds.has(runtimeService.id)
&& (runtimeService.status === "starting" || runtimeService.status === "running"))
&& (runtimeService.status === "provisioning"
|| runtimeService.status === "starting"
|| runtimeService.status === "running"))
.map((runtimeService) => ({
key: `runtime:${runtimeService.id}`,
title: runtimeService.serviceName,
@ -208,7 +210,7 @@ export function getRunningRuntimeServiceUrl(
}
function isActiveStatusLabel(statusLabel: string) {
return statusLabel === "running" || statusLabel === "starting";
return statusLabel === "running" || statusLabel === "starting" || statusLabel === "provisioning";
}
/**
@ -233,11 +235,13 @@ export function buildWorkspaceServiceControlEntries(input: {
let state: WorkspaceServiceControlEntry["state"] =
item.statusLabel === "running"
? "running"
: item.statusLabel === "starting"
? "starting"
: item.statusLabel === "failed"
? "failed"
: "stopped";
: item.statusLabel === "provisioning"
? "provisioning"
: item.statusLabel === "starting"
? "starting"
: item.statusLabel === "failed"
? "failed"
: "stopped";
const pendingRequest = pendingRequests.find((request) =>
request.action !== "run"

View File

@ -16,6 +16,7 @@ import { cn } from "@/lib/utils";
export type WorkspaceServiceControlState =
| "stopped"
| "provisioning"
| "starting"
| "running"
| "stopping"
@ -48,7 +49,7 @@ export type WorkspaceServiceControlBarProps = {
className?: string;
};
const TRANSITIONAL_STATES: WorkspaceServiceControlState[] = ["starting", "stopping", "restarting"];
const TRANSITIONAL_STATES: WorkspaceServiceControlState[] = ["provisioning", "starting", "stopping", "restarting"];
function isTransitional(state: WorkspaceServiceControlState) {
return TRANSITIONAL_STATES.includes(state);
@ -61,6 +62,8 @@ function formatServiceUrl(url: string | null | undefined) {
function statusMeta(entry: WorkspaceServiceControlEntry): { label: string; unhealthy: boolean } {
switch (entry.state) {
case "provisioning":
return { label: "Provisioning…", unhealthy: false };
case "starting":
return { label: "Starting…", unhealthy: false };
case "stopping":

View File

@ -0,0 +1,92 @@
import type { WorkspaceOperation } from "@paperclipai/shared";
import { describe, expect, it } from "vitest";
import { resolveRuntimeProvisionStatus } from "./ExecutionWorkspaceDetail";
function operation(overrides: Partial<WorkspaceOperation> = {}): WorkspaceOperation {
return {
id: overrides.id ?? "op-1",
companyId: "company-1",
executionWorkspaceId: "ews-1",
heartbeatRunId: null,
issueId: null,
phase: overrides.phase ?? "workspace_runtime_provision",
command: overrides.command ?? "bash ./scripts/provision-worktree-runtime.sh",
cwd: null,
status: overrides.status ?? "succeeded",
exitCode: overrides.exitCode ?? 0,
logStore: null,
logRef: null,
logBytes: null,
logSha256: null,
logCompressed: false,
stdoutExcerpt: null,
stderrExcerpt: null,
metadata: null,
startedAt: overrides.startedAt ?? new Date("2026-08-01T00:00:00.000Z"),
finishedAt: "finishedAt" in overrides ? overrides.finishedAt! : new Date("2026-08-01T00:01:00.000Z"),
createdAt: new Date("2026-08-01T00:00:00.000Z"),
updatedAt: new Date("2026-08-01T00:01:00.000Z"),
};
}
describe("resolveRuntimeProvisionStatus", () => {
it("reports eager when no runtime provision command is configured", () => {
expect(resolveRuntimeProvisionStatus({ runtimeProvisionCommand: null, operations: [] })).toEqual({
kind: "eager",
});
expect(resolveRuntimeProvisionStatus({ runtimeProvisionCommand: " ", operations: undefined })).toEqual({
kind: "eager",
});
});
it("reports deferred when configured but no provision operation has run", () => {
expect(
resolveRuntimeProvisionStatus({
runtimeProvisionCommand: "bash ./seed.sh",
operations: [operation({ phase: "workspace_provision" })],
}),
).toEqual({ kind: "deferred" });
});
it("reports provisioned with the finished time when the latest op succeeded", () => {
const finishedAt = new Date("2026-08-01T12:00:00.000Z");
expect(
resolveRuntimeProvisionStatus({
runtimeProvisionCommand: "bash ./seed.sh",
operations: [operation({ status: "succeeded", finishedAt })],
}),
).toEqual({ kind: "provisioned", at: finishedAt });
});
it("reports provisioning while the op is running", () => {
const startedAt = new Date("2026-08-01T12:00:00.000Z");
expect(
resolveRuntimeProvisionStatus({
runtimeProvisionCommand: "bash ./seed.sh",
operations: [operation({ status: "running", startedAt, finishedAt: null })],
}),
).toEqual({ kind: "provisioning", at: startedAt });
});
it("reports failed with the finished time when the latest op failed", () => {
const finishedAt = new Date("2026-08-01T12:05:00.000Z");
expect(
resolveRuntimeProvisionStatus({
runtimeProvisionCommand: "bash ./seed.sh",
operations: [operation({ status: "failed", finishedAt, exitCode: 1 })],
}),
).toEqual({ kind: "failed", at: finishedAt });
});
it("uses only the latest runtime provision op (operations are most-recent first)", () => {
const latest = new Date("2026-08-02T00:00:00.000Z");
const status = resolveRuntimeProvisionStatus({
runtimeProvisionCommand: "bash ./seed.sh",
operations: [
operation({ id: "op-new", status: "failed", finishedAt: latest, exitCode: 1 }),
operation({ id: "op-old", status: "succeeded" }),
],
});
expect(status).toEqual({ kind: "failed", at: latest });
});
});

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Link, Navigate, useLocation, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace, RoutineListItem } from "@paperclipai/shared";
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace, RoutineListItem, WorkspaceOperation } from "@paperclipai/shared";
import { Copy, ExternalLink, Loader2, Play, Repeat } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from "@/components/ui/card";
@ -55,6 +55,7 @@ type WorkspaceFormState = {
branchName: string;
providerRef: string;
provisionCommand: string;
runtimeProvisionCommand: string;
teardownCommand: string;
cleanupCommand: string;
inheritRuntime: boolean;
@ -271,6 +272,7 @@ function formStateFromWorkspace(workspace: ExecutionWorkspace): WorkspaceFormSta
branchName: readText(workspace.branchName),
providerRef: readText(workspace.providerRef),
provisionCommand: readText(workspace.config?.provisionCommand),
runtimeProvisionCommand: readText(workspace.config?.runtimeProvisionCommand),
teardownCommand: readText(workspace.config?.teardownCommand),
cleanupCommand: readText(workspace.config?.cleanupCommand),
inheritRuntime: !workspace.config?.workspaceRuntime,
@ -296,12 +298,13 @@ function buildWorkspacePatch(initialState: WorkspaceFormState, nextState: Worksp
maybeAssign("branchName");
maybeAssign("providerRef");
const maybeAssignConfigText = (key: keyof Pick<WorkspaceFormState, "provisionCommand" | "teardownCommand" | "cleanupCommand">) => {
const maybeAssignConfigText = (key: keyof Pick<WorkspaceFormState, "provisionCommand" | "runtimeProvisionCommand" | "teardownCommand" | "cleanupCommand">) => {
if (initialState[key] === nextState[key]) return;
configPatch[key] = normalizeText(nextState[key]);
};
maybeAssignConfigText("provisionCommand");
maybeAssignConfigText("runtimeProvisionCommand");
maybeAssignConfigText("teardownCommand");
maybeAssignConfigText("cleanupCommand");
@ -368,6 +371,8 @@ function workspaceOperationPhaseLabel(phase: string) {
return "Config freshness";
case "workspace_provision":
return "Provision";
case "workspace_runtime_provision":
return "Runtime provision";
case "workspace_teardown":
return "Teardown";
case "worktree_cleanup":
@ -379,6 +384,34 @@ function workspaceOperationPhaseLabel(phase: string) {
}
}
export type RuntimeProvisionStatus =
| { kind: "eager" }
| { kind: "deferred" }
| { kind: "provisioning"; at: Date | null }
| { kind: "provisioned"; at: Date | null }
| { kind: "failed"; at: Date | null };
/**
* Derives the lazy runtime-provisioning state from the configured command and the
* `workspace_runtime_provision` operation-log entries (most-recent first). Returns
* "eager" when no runtime provision command is configured (the legacy path).
*/
export function resolveRuntimeProvisionStatus(input: {
runtimeProvisionCommand: string | null | undefined;
operations: WorkspaceOperation[] | undefined;
}): RuntimeProvisionStatus {
const latest = (input.operations ?? []).find((operation) => operation.phase === "workspace_runtime_provision") ?? null;
if (latest) {
const at = latest.finishedAt ?? latest.startedAt ?? null;
if (latest.status === "running") return { kind: "provisioning", at };
if (latest.status === "succeeded") return { kind: "provisioned", at };
if (latest.status === "failed") return { kind: "failed", at };
// "skipped" falls through to the config-derived state below.
}
const configured = Boolean(input.runtimeProvisionCommand && input.runtimeProvisionCommand.trim());
return configured ? { kind: "deferred" } : { kind: "eager" };
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5 py-1.5 sm:flex-row sm:items-start sm:gap-3">
@ -396,6 +429,55 @@ function StatusPill({ children, className }: { children: React.ReactNode; classN
);
}
export function RuntimeProvisionStatusValue({
status,
onViewLogs,
}: {
status: RuntimeProvisionStatus;
onViewLogs: () => void;
}) {
if (status.kind === "eager") {
return (
<span className="text-sm text-muted-foreground">Eager · provisioned during workspace setup</span>
);
}
if (status.kind === "deferred") {
return (
<div className="flex flex-col gap-1">
<StatusPill className="border-amber-500/40 text-amber-600 dark:text-amber-400">Deferred</StatusPill>
<span className="text-xs text-muted-foreground">
Runs once before the first runtime-service start.
</span>
</div>
);
}
if (status.kind === "provisioning") {
return (
<StatusPill className="border-border text-muted-foreground">
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
Provisioning
</StatusPill>
);
}
if (status.kind === "provisioned") {
return (
<StatusPill className="border-emerald-500/40 text-emerald-600 dark:text-emerald-400">
Provisioned{status.at ? ` · ${formatDateTime(status.at)}` : ""}
</StatusPill>
);
}
return (
<div className="flex flex-col gap-1">
<StatusPill className="border-destructive/50 text-destructive">
Provisioning failed{status.at ? ` · ${formatDateTime(status.at)}` : ""}
</StatusPill>
<button type="button" onClick={onViewLogs} className="self-start text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground">
View runtime logs
</button>
</div>
);
}
function MonoValue({ value, copy }: { value: string; copy?: boolean }) {
return (
<div className="inline-flex max-w-full items-start gap-2">
@ -846,6 +928,18 @@ export function ExecutionWorkspaceDetail() {
queryFn: () => executionWorkspacesApi.listWorkspaceOperations(workspaceId!),
enabled: Boolean(workspaceId),
});
const runtimeProvisionCommand =
workspace?.config?.runtimeProvisionCommand
?? project?.executionWorkspacePolicy?.workspaceStrategy?.runtimeProvisionCommand
?? null;
const runtimeProvisionStatus = useMemo(
() =>
resolveRuntimeProvisionStatus({
runtimeProvisionCommand,
operations: workspaceOperationsQuery.data,
}),
[runtimeProvisionCommand, workspaceOperationsQuery.data],
);
const controlRuntimeServices = useMutation({
mutationFn: (request: WorkspaceRuntimeControlRequest) =>
executionWorkspacesApi.controlRuntimeCommands(workspace!.id, request.action, request),
@ -1108,6 +1202,18 @@ export function ExecutionWorkspaceDetail() {
/>
</Field>
<Field
label="Runtime provision command"
hint="Runs once before the first runtime-service start. Leave empty to keep eager provisioning."
>
<Textarea
className="min-h-20 font-mono"
value={form.runtimeProvisionCommand}
onChange={(event) => setForm((current) => current ? { ...current, runtimeProvisionCommand: event.target.value } : current)}
placeholder="bash ./scripts/provision-worktree-runtime.sh"
/>
</Field>
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
<Textarea
className="min-h-20 font-mono"
@ -1316,6 +1422,12 @@ export function ExecutionWorkspaceDetail() {
"None"
)}
</DetailRow>
<DetailRow label="Runtime provisioning">
<RuntimeProvisionStatusValue
status={runtimeProvisionStatus}
onViewLogs={() => handleTabChange("runtime_logs")}
/>
</DetailRow>
<DetailRow label="Workspace ID">
<MonoValue value={workspace.id} />
</DetailRow>

View File

@ -608,6 +608,7 @@ function createProject(overrides: Partial<Project> = {}): Project {
branchTemplate: "{issueIdentifier}-{slug}",
worktreeParentDir: storybookWorktreeRoot,
provisionCommand: null,
runtimeProvisionCommand: "bash ./scripts/provision-worktree-runtime.sh",
teardownCommand: null,
},
workspaceRuntime: null,

View File

@ -0,0 +1,58 @@
import { useState, type ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useQueryClient } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "@/components/ProjectProperties";
import { queryKeys } from "@/lib/queryKeys";
import { storybookProjects } from "../fixtures/paperclipData";
const COMPANY_ID = "company-storybook";
const boardProject = storybookProjects.find((project) => project.id === "project-board-ui") ?? storybookProjects[0]!;
function fieldState(field: ProjectConfigFieldKey): ProjectFieldSaveState {
return field === "execution_workspace_runtime_provision_command" ? "saved" : "idle";
}
function Hydrate({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
useState(() => {
queryClient.setQueryData(queryKeys.instance.experimentalSettings, {
enableIsolatedWorkspaces: true,
enableRoutineTriggers: true,
enableEnvironments: false,
});
queryClient.setQueryData(queryKeys.secrets.list(COMPANY_ID), []);
return true;
});
return children;
}
const meta: Meta<typeof ProjectProperties> = {
title: "Workspaces/Project runtime provision command",
component: ProjectProperties,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof ProjectProperties>;
const editableProject: Project = {
...boardProject,
env: null,
};
export const IsolatedStrategy: Story = {
name: "Isolated workspace strategy (advanced open)",
render: () => (
<Hydrate>
<div className="max-w-2xl rounded-lg border border-border bg-background p-4">
<ProjectProperties
project={editableProject}
onFieldUpdate={() => undefined}
getFieldSaveState={fieldState}
onArchive={() => undefined}
/>
</div>
</Hydrate>
),
};

View File

@ -0,0 +1,93 @@
import type { ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RuntimeProvisionStatusValue, type RuntimeProvisionStatus } from "@/pages/ExecutionWorkspaceDetail";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
const noop = () => {};
// Mirrors the DetailRow layout used on the execution workspace detail page so the
// captured story matches how the "Runtime provisioning" row renders in context.
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-1.5 py-1.5 sm:flex-row sm:items-start sm:gap-3">
<div className="shrink-0 text-xs text-muted-foreground sm:w-32">{label}</div>
<div className="min-w-0 flex-1 text-sm">{children}</div>
</div>
);
}
function ContextCard({ status }: { status: RuntimeProvisionStatus }) {
return (
<Card className="max-w-xl rounded-none">
<CardHeader>
<CardTitle>Workspace context</CardTitle>
<CardDescription>Linked objects and relationships</CardDescription>
</CardHeader>
<CardContent>
<DetailRow label="Project">board-ui</DetailRow>
<DetailRow label="Runtime provisioning">
<RuntimeProvisionStatusValue status={status} onViewLogs={noop} />
</DetailRow>
<DetailRow label="Workspace ID">
<span className="font-mono text-xs">ews-7f21c9a3</span>
</DetailRow>
</CardContent>
</Card>
);
}
const meta: Meta<typeof RuntimeProvisionStatusValue> = {
title: "Workspaces/Runtime provisioning status",
component: RuntimeProvisionStatusValue,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof RuntimeProvisionStatusValue>;
export const Deferred: Story = {
name: "Deferred (configured, not yet run)",
render: () => <ContextCard status={{ kind: "deferred" }} />,
};
export const Provisioned: Story = {
name: "Provisioned at <time>",
render: () => <ContextCard status={{ kind: "provisioned", at: new Date("2026-08-01T18:24:00Z") }} />,
};
export const Provisioning: Story = {
name: "Provisioning… (running)",
render: () => <ContextCard status={{ kind: "provisioning", at: new Date("2026-08-01T18:23:00Z") }} />,
};
export const Failed: Story = {
name: "Failed (links to runtime logs)",
render: () => <ContextCard status={{ kind: "failed", at: new Date("2026-08-01T18:24:00Z") }} />,
};
export const Eager: Story = {
name: "Eager (no runtime provision command)",
render: () => <ContextCard status={{ kind: "eager" }} />,
};
export const AllStates: Story = {
name: "All states (overview)",
render: () => (
<div className="flex max-w-md flex-col gap-4">
{(
[
["Eager", { kind: "eager" }],
["Deferred", { kind: "deferred" }],
["Provisioning", { kind: "provisioning", at: new Date("2026-08-01T18:23:00Z") }],
["Provisioned", { kind: "provisioned", at: new Date("2026-08-01T18:24:00Z") }],
["Failed", { kind: "failed", at: new Date("2026-08-01T18:24:00Z") }],
] as const
).map(([label, status]) => (
<div key={label} className="flex items-start justify-between gap-6 border-b border-border/60 pb-3">
<span className="text-sm font-medium text-muted-foreground">{label}</span>
<RuntimeProvisionStatusValue status={status} onViewLogs={noop} />
</div>
))}
</div>
),
};

View File

@ -41,6 +41,11 @@ export const Stopped: Story = {
args: { services: [entry({ state: "stopped" })] },
};
export const Provisioning: Story = {
name: "Provisioning (lazy runtime setup)",
args: { services: [entry({ state: "provisioning", url: null, port: null })] },
};
export const Starting: Story = {
args: { services: [entry({ state: "starting" })] },
};
@ -195,6 +200,7 @@ export const AllStates: Story = {
{(
[
["Stopped", entry({ state: "stopped" })],
["Provisioning", entry({ state: "provisioning", url: null, port: null })],
["Starting", entry({ state: "starting" })],
["Running", entry()],
["Unhealthy", entry({ healthStatus: "unhealthy" })],