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:
parent
8540ce2973
commit
dcac49a4fd
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -19,14 +19,15 @@ You can define how to run a project on the project workspace itself.
|
|||
- This is the default runtime configuration that child execution workspaces may inherit.
|
||||
- Defining the config does not start anything by itself.
|
||||
|
||||
## Manual runtime control
|
||||
## Runtime control: manual and heartbeat-driven
|
||||
|
||||
Workspace commands are manually controlled from the UI.
|
||||
Workspace commands can be controlled manually from the UI, and heartbeat runs also start services automatically.
|
||||
|
||||
- Project workspace services are started and stopped from the project workspace UI, and project jobs can be run on demand there.
|
||||
- Execution workspace services are started and stopped from the execution workspace UI, and execution-workspace jobs can be run on demand there.
|
||||
- Paperclip does not automatically start or stop these workspace services as part of issue execution.
|
||||
- Paperclip also does not automatically restart workspace services on server boot.
|
||||
- Heartbeat runs also auto-start the workspace's runtime services at the beginning of an issue run. `ensureRuntimeServicesForRun` (`server/src/services/workspace-runtime.ts`, called from `server/src/services/heartbeat.ts`) starts each service whose desired state resolves to `running` — which is the default when no explicit per-service desired state is set. A running service that matches an existing reuse key is reused rather than restarted.
|
||||
- You can opt a service out of that auto-start by setting its desired state to `stopped`/`manual` in the runtime config; those services stay UI-controlled.
|
||||
- Paperclip does not automatically restart workspace services on server boot — services only come back up when the next run (or a manual start) brings them up.
|
||||
|
||||
## Execution workspace inheritance
|
||||
|
||||
|
|
@ -44,7 +45,7 @@ Issues are attached to execution workspace behavior, not to automatic runtime ma
|
|||
- An issue may create a new execution workspace when you choose an isolated workspace mode.
|
||||
- An issue may reuse an existing execution workspace when you choose reuse.
|
||||
- Multiple issues may intentionally share one execution workspace so they can work against the same branch and running runtime services.
|
||||
- Assigning or running an issue does not automatically start or stop workspace services for that workspace.
|
||||
- Running an issue auto-starts the workspace's `running`-desired runtime services for the duration of the run (see "Runtime control" above); it does not stop them when the run ends unless they are ephemeral and no other run holds a lease.
|
||||
|
||||
## Execution workspace lifecycle
|
||||
|
||||
|
|
@ -56,13 +57,25 @@ Execution workspaces are durable until a human closes them.
|
|||
|
||||
## Resolved workspace logic during heartbeat runs
|
||||
|
||||
Heartbeat still resolves a workspace for the run, but that is about code location and session continuity, not runtime-service control.
|
||||
Heartbeat resolves a workspace for the run (code location and session continuity) and also brings up that workspace's runtime services.
|
||||
|
||||
1. Heartbeat resolves a base workspace for the run.
|
||||
2. Paperclip realizes the effective execution workspace, including creating or reusing a worktree when needed.
|
||||
3. Paperclip persists execution-workspace metadata such as paths, refs, and provisioning settings.
|
||||
4. Heartbeat passes the resolved code workspace to the agent run.
|
||||
5. Workspace runtime services remain manual UI-managed controls rather than automatic heartbeat-managed services.
|
||||
5. Heartbeat calls `ensureRuntimeServicesForRun` to start the workspace's `running`-desired runtime services, running the lazy runtime provision command first if one is configured and has not yet run (see "Lazy runtime provisioning" below).
|
||||
|
||||
## Lazy runtime provisioning
|
||||
|
||||
Some workspaces need heavy one-time setup — seeding a database, warming caches — before their runtime services can start. That work can be deferred to the first runtime-service start instead of running eagerly during workspace preparation.
|
||||
|
||||
- Configure a **runtime provision command** on the project's workspace strategy (Project properties → execution workspace), or override it per execution workspace on the workspace's Configuration tab.
|
||||
- When set, workspace preparation stays lean and the command runs exactly once, immediately before the first runtime-service start for that workspace. Leaving it empty keeps the legacy eager path (all setup during workspace provisioning).
|
||||
- The command's outcome is recorded as a `workspace_runtime_provision` operation on the execution workspace and surfaced on the workspace detail page:
|
||||
- **Deferred** — configured but not yet run (no runtime service has started yet).
|
||||
- **Provisioned at <time>** — the command completed successfully.
|
||||
- **Provisioning failed** — the command failed; the workspace detail links to the runtime logs for the failing operation.
|
||||
- While the command runs, the runtime service shows a **Provisioning…** state before it transitions to starting/running.
|
||||
|
||||
## Cross-run persistence (no-remote-git contract)
|
||||
|
||||
|
|
@ -81,5 +94,6 @@ With the current implementation:
|
|||
|
||||
- Project workspace command config is the fallback for execution workspace UI controls.
|
||||
- Execution workspace runtime overrides are stored on the execution workspace.
|
||||
- Heartbeat runs do not auto-start workspace services.
|
||||
- Heartbeat runs auto-start the workspace's `running`-desired runtime services (via `ensureRuntimeServicesForRun`); services set to `stopped`/`manual` stay UI-controlled.
|
||||
- A configured runtime provision command runs once, lazily, before the first runtime-service start.
|
||||
- Server startup does not auto-restart workspace services.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export type WorkspaceOperationPhase =
|
|||
| "worktree_prepare"
|
||||
| "workspace_config_freshness"
|
||||
| "workspace_provision"
|
||||
| "workspace_runtime_provision"
|
||||
| "workspace_teardown"
|
||||
| "worktree_cleanup"
|
||||
| "workspace_finalize";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1068,6 +1068,7 @@ describe("mergeExecutionWorkspaceMetadataForPersistence", () => {
|
|||
config: {
|
||||
environmentId: "env-new",
|
||||
provisionCommand: "bash ./scripts/provision.sh",
|
||||
runtimeProvisionCommand: null,
|
||||
teardownCommand: null,
|
||||
cleanupCommand: null,
|
||||
desiredState: null,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
};
|
||||
|
|
@ -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>
|
||||
),
|
||||
};
|
||||
|
|
@ -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" })],
|
||||
|
|
|
|||
Loading…
Reference in New Issue