import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { cleanupExecutionWorkspaceArtifacts, ensureRuntimeServicesForRun, normalizeAdapterManagedRuntimeServices, realizeExecutionWorkspace, releaseRuntimeServicesForRun, stopRuntimeServicesForExecutionWorkspace, type RealizedExecutionWorkspace, } from "../services/workspace-runtime.ts"; const execFileAsync = promisify(execFile); const leasedRunIds = new Set(); async function runGit(cwd: string, args: string[]) { await execFileAsync("git", args, { cwd }); } async function createTempRepo() { const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repo-")); await runGit(repoRoot, ["init"]); await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]); await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]); await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8"); await runGit(repoRoot, ["add", "README.md"]); await runGit(repoRoot, ["commit", "-m", "Initial commit"]); await runGit(repoRoot, ["checkout", "-B", "main"]); return repoRoot; } function buildWorkspace(cwd: string): RealizedExecutionWorkspace { return { baseCwd: cwd, source: "project_primary", projectId: "project-1", workspaceId: "workspace-1", repoUrl: null, repoRef: "HEAD", strategy: "project_primary", cwd, branchName: null, worktreePath: null, warnings: [], created: false, }; } afterEach(async () => { await Promise.all( Array.from(leasedRunIds).map(async (runId) => { await releaseRuntimeServicesForRun(runId); leasedRunIds.delete(runId); }), ); delete process.env.PAPERCLIP_CONFIG; delete process.env.PAPERCLIP_HOME; delete process.env.PAPERCLIP_INSTANCE_ID; delete process.env.DATABASE_URL; }); describe("realizeExecutionWorkspace", () => { it("creates and reuses a git worktree for an issue-scoped branch", async () => { const repoRoot = await createTempRepo(); const first = await realizeExecutionWorkspace({ base: { baseCwd: repoRoot, source: "project_primary", projectId: "project-1", workspaceId: "workspace-1", repoUrl: null, repoRef: "HEAD", }, config: { workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}-{{slug}}", }, }, issue: { id: "issue-1", identifier: "PAP-447", title: "Add Worktree Support", }, agent: { id: "agent-1", name: "Codex Coder", companyId: "company-1", }, }); expect(first.strategy).toBe("git_worktree"); expect(first.created).toBe(true); expect(first.branchName).toBe("PAP-447-add-worktree-support"); expect(first.cwd).toContain(path.join(".paperclip", "worktrees")); await expect(fs.stat(path.join(first.cwd, ".git"))).resolves.toBeTruthy(); const second = await realizeExecutionWorkspace({ base: { baseCwd: repoRoot, source: "project_primary", projectId: "project-1", workspaceId: "workspace-1", repoUrl: null, repoRef: "HEAD", }, config: { workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}-{{slug}}", }, }, issue: { id: "issue-1", identifier: "PAP-447", title: "Add Worktree Support", }, agent: { id: "agent-1", name: "Codex Coder", companyId: "company-1", }, }); expect(second.created).toBe(false); expect(second.cwd).toBe(first.cwd); expect(second.branchName).toBe(first.branchName); }); it("runs a configured provision command inside the derived worktree", async () => { const repoRoot = await createTempRepo(); await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); await fs.writeFile( path.join(repoRoot, "scripts", "provision.sh"), [ "#!/usr/bin/env bash", "set -euo pipefail", "printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BRANCH\" > .paperclip-provision-branch", "printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BASE_CWD\" > .paperclip-provision-base", "printf '%s\\n' \"$PAPERCLIP_WORKSPACE_CREATED\" > .paperclip-provision-created", ].join("\n"), "utf8", ); await runGit(repoRoot, ["add", "scripts/provision.sh"]); await runGit(repoRoot, ["commit", "-m", "Add worktree provision script"]); const workspace = await realizeExecutionWorkspace({ base: { baseCwd: repoRoot, source: "project_primary", projectId: "project-1", workspaceId: "workspace-1", repoUrl: null, repoRef: "HEAD", }, config: { workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}-{{slug}}", provisionCommand: "bash ./scripts/provision.sh", }, }, issue: { id: "issue-1", identifier: "PAP-448", title: "Run provision command", }, agent: { id: "agent-1", name: "Codex Coder", companyId: "company-1", }, }); await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-branch"), "utf8")).resolves.toBe( "PAP-448-run-provision-command\n", ); await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-base"), "utf8")).resolves.toBe( `${repoRoot}\n`, ); await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe( "true\n", ); const reused = await realizeExecutionWorkspace({ base: { baseCwd: repoRoot, source: "project_primary", projectId: "project-1", workspaceId: "workspace-1", repoUrl: null, repoRef: "HEAD", }, config: { workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}-{{slug}}", provisionCommand: "bash ./scripts/provision.sh", }, }, issue: { id: "issue-1", identifier: "PAP-448", title: "Run provision command", }, agent: { id: "agent-1", name: "Codex Coder", companyId: "company-1", }, }); await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n"); }); it("removes a created git worktree and branch during cleanup", async () => { const repoRoot = await createTempRepo(); const workspace = await realizeExecutionWorkspace({ base: { baseCwd: repoRoot, source: "project_primary", projectId: "project-1", workspaceId: "workspace-1", repoUrl: null, repoRef: "HEAD", }, config: { workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}-{{slug}}", }, }, issue: { id: "issue-1", identifier: "PAP-449", title: "Cleanup workspace", }, agent: { id: "agent-1", name: "Codex Coder", companyId: "company-1", }, }); const cleanup = await cleanupExecutionWorkspaceArtifacts({ workspace: { id: "execution-workspace-1", cwd: workspace.cwd, providerType: "git_worktree", providerRef: workspace.worktreePath, branchName: workspace.branchName, repoUrl: workspace.repoUrl, baseRef: workspace.repoRef, projectId: workspace.projectId, projectWorkspaceId: workspace.workspaceId, sourceIssueId: "issue-1", metadata: { createdByRuntime: true, }, }, projectWorkspace: { cwd: repoRoot, cleanupCommand: null, }, }); expect(cleanup.cleaned).toBe(true); expect(cleanup.warnings).toEqual([]); await expect(fs.stat(workspace.cwd)).rejects.toThrow(); await expect( execFileAsync("git", ["branch", "--list", workspace.branchName!], { cwd: repoRoot }), ).resolves.toMatchObject({ stdout: "", }); }); }); describe("ensureRuntimeServicesForRun", () => { it("reuses shared runtime services across runs and starts a new service after release", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-workspace-")); const workspace = buildWorkspace(workspaceRoot); const serviceCommand = "node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\""; const config = { workspaceRuntime: { services: [ { name: "web", command: serviceCommand, port: { type: "auto" }, readiness: { type: "http", urlTemplate: "http://127.0.0.1:{{port}}", timeoutSec: 10, intervalMs: 100, }, expose: { type: "url", urlTemplate: "http://127.0.0.1:{{port}}", }, lifecycle: "shared", reuseScope: "project_workspace", stopPolicy: { type: "on_run_finish", }, }, ], }, }; const run1 = "run-1"; const run2 = "run-2"; leasedRunIds.add(run1); leasedRunIds.add(run2); const first = await ensureRuntimeServicesForRun({ runId: run1, agent: { id: "agent-1", name: "Codex Coder", companyId: "company-1", }, issue: null, workspace, config, adapterEnv: {}, }); expect(first).toHaveLength(1); expect(first[0]?.reused).toBe(false); expect(first[0]?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); const response = await fetch(first[0]!.url!); expect(await response.text()).toBe("ok"); const second = await ensureRuntimeServicesForRun({ runId: run2, agent: { id: "agent-1", name: "Codex Coder", companyId: "company-1", }, issue: null, workspace, config, adapterEnv: {}, }); expect(second).toHaveLength(1); expect(second[0]?.reused).toBe(true); expect(second[0]?.id).toBe(first[0]?.id); await releaseRuntimeServicesForRun(run1); leasedRunIds.delete(run1); await releaseRuntimeServicesForRun(run2); leasedRunIds.delete(run2); const run3 = "run-3"; leasedRunIds.add(run3); const third = await ensureRuntimeServicesForRun({ runId: run3, agent: { id: "agent-1", name: "Codex Coder", companyId: "company-1", }, issue: null, workspace, config, adapterEnv: {}, }); expect(third).toHaveLength(1); expect(third[0]?.reused).toBe(false); expect(third[0]?.id).not.toBe(first[0]?.id); }); it("does not leak parent Paperclip instance env into runtime service commands", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-env-")); const workspace = buildWorkspace(workspaceRoot); const envCapturePath = path.join(workspaceRoot, "captured-env.json"); const serviceCommand = [ "node -e", JSON.stringify( [ "const fs = require('node:fs');", `fs.writeFileSync(${JSON.stringify(envCapturePath)}, JSON.stringify({`, "paperclipConfig: process.env.PAPERCLIP_CONFIG ?? null,", "paperclipHome: process.env.PAPERCLIP_HOME ?? null,", "paperclipInstanceId: process.env.PAPERCLIP_INSTANCE_ID ?? null,", "databaseUrl: process.env.DATABASE_URL ?? null,", "customEnv: process.env.RUNTIME_CUSTOM_ENV ?? null,", "port: process.env.PORT ?? null,", "}));", "require('node:http').createServer((req, res) => res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1');", ].join(" "), ), ].join(" "); process.env.PAPERCLIP_CONFIG = "/tmp/base-paperclip-config.json"; process.env.PAPERCLIP_HOME = "/tmp/base-paperclip-home"; process.env.PAPERCLIP_INSTANCE_ID = "base-instance"; process.env.DATABASE_URL = "postgres://shared-db.example.com/paperclip"; const runId = "run-env"; leasedRunIds.add(runId); const services = await ensureRuntimeServicesForRun({ runId, agent: { id: "agent-1", name: "Codex Coder", companyId: "company-1", }, issue: null, workspace, executionWorkspaceId: "execution-workspace-1", config: { workspaceRuntime: { services: [ { name: "web", command: serviceCommand, port: { type: "auto" }, readiness: { type: "http", urlTemplate: "http://127.0.0.1:{{port}}", timeoutSec: 10, intervalMs: 100, }, lifecycle: "shared", reuseScope: "execution_workspace", stopPolicy: { type: "on_run_finish", }, }, ], }, }, adapterEnv: { RUNTIME_CUSTOM_ENV: "from-adapter", }, }); expect(services).toHaveLength(1); const captured = JSON.parse(await fs.readFile(envCapturePath, "utf8")) as Record; expect(captured.paperclipConfig).toBeNull(); expect(captured.paperclipHome).toBeNull(); expect(captured.paperclipInstanceId).toBeNull(); expect(captured.databaseUrl).toBeNull(); expect(captured.customEnv).toBe("from-adapter"); expect(captured.port).toMatch(/^\d+$/); expect(services[0]?.executionWorkspaceId).toBe("execution-workspace-1"); }); it("stops execution workspace runtime services by executionWorkspaceId", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-")); const workspace = buildWorkspace(workspaceRoot); const runId = "run-stop"; leasedRunIds.add(runId); const services = await ensureRuntimeServicesForRun({ runId, agent: { id: "agent-1", name: "Codex Coder", companyId: "company-1", }, issue: null, workspace, executionWorkspaceId: "execution-workspace-stop", config: { workspaceRuntime: { services: [ { name: "web", command: "node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"", port: { type: "auto" }, readiness: { type: "http", urlTemplate: "http://127.0.0.1:{{port}}", timeoutSec: 10, intervalMs: 100, }, lifecycle: "shared", reuseScope: "execution_workspace", stopPolicy: { type: "manual", }, }, ], }, }, adapterEnv: {}, }); expect(services[0]?.url).toBeTruthy(); await stopRuntimeServicesForExecutionWorkspace({ executionWorkspaceId: "execution-workspace-stop", workspaceCwd: workspace.cwd, }); await releaseRuntimeServicesForRun(runId); leasedRunIds.delete(runId); await new Promise((resolve) => setTimeout(resolve, 250)); await expect(fetch(services[0]!.url!)).rejects.toThrow(); }); }); describe("normalizeAdapterManagedRuntimeServices", () => { it("fills workspace defaults and derives stable ids for adapter-managed services", () => { const workspace = buildWorkspace("/tmp/project"); const now = new Date("2026-03-09T12:00:00.000Z"); const first = normalizeAdapterManagedRuntimeServices({ adapterType: "openclaw_gateway", runId: "run-1", agent: { id: "agent-1", name: "Gateway Agent", companyId: "company-1", }, issue: { id: "issue-1", identifier: "PAP-447", title: "Worktree support", }, workspace, reports: [ { serviceName: "preview", url: "https://preview.example/run-1", providerRef: "sandbox-123", scopeType: "run", }, ], now, }); const second = normalizeAdapterManagedRuntimeServices({ adapterType: "openclaw_gateway", runId: "run-1", agent: { id: "agent-1", name: "Gateway Agent", companyId: "company-1", }, issue: { id: "issue-1", identifier: "PAP-447", title: "Worktree support", }, workspace, reports: [ { serviceName: "preview", url: "https://preview.example/run-1", providerRef: "sandbox-123", scopeType: "run", }, ], now, }); expect(first).toHaveLength(1); expect(first[0]).toMatchObject({ companyId: "company-1", projectId: "project-1", projectWorkspaceId: "workspace-1", executionWorkspaceId: null, issueId: "issue-1", serviceName: "preview", provider: "adapter_managed", status: "running", healthStatus: "healthy", startedByRunId: "run-1", }); expect(first[0]?.id).toBe(second[0]?.id); }); });