import { spawn, type ChildProcess } from "node:child_process"; import fs from "node:fs/promises"; import net from "node:net"; import { createHash, randomUUID } from "node:crypto"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import type { AdapterRuntimeServiceReport } from "@paperclipai/adapter-utils"; import type { Db } from "@paperclipai/db"; import { workspaceRuntimeServices } from "@paperclipai/db"; import { and, desc, eq, inArray } from "drizzle-orm"; import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js"; import { resolveHomeAwarePath } from "../home-paths.js"; import type { WorkspaceOperationRecorder } from "./workspace-operations.js"; export interface ExecutionWorkspaceInput { baseCwd: string; source: "project_primary" | "task_session" | "agent_home"; projectId: string | null; workspaceId: string | null; repoUrl: string | null; repoRef: string | null; } export interface ExecutionWorkspaceIssueRef { id: string; identifier: string | null; title: string | null; } export interface ExecutionWorkspaceAgentRef { id: string; name: string; companyId: string; } export interface RealizedExecutionWorkspace extends ExecutionWorkspaceInput { strategy: "project_primary" | "git_worktree"; cwd: string; branchName: string | null; worktreePath: string | null; warnings: string[]; created: boolean; } export interface RuntimeServiceRef { id: string; companyId: string; projectId: string | null; projectWorkspaceId: string | null; executionWorkspaceId: string | null; issueId: string | null; serviceName: string; status: "starting" | "running" | "stopped" | "failed"; lifecycle: "shared" | "ephemeral"; scopeType: "project_workspace" | "execution_workspace" | "run" | "agent"; scopeId: string | null; reuseKey: string | null; command: string | null; cwd: string | null; port: number | null; url: string | null; provider: "local_process" | "adapter_managed"; providerRef: string | null; ownerAgentId: string | null; startedByRunId: string | null; lastUsedAt: string; startedAt: string; stoppedAt: string | null; stopPolicy: Record | null; healthStatus: "unknown" | "healthy" | "unhealthy"; reused: boolean; } interface RuntimeServiceRecord extends RuntimeServiceRef { db?: Db; child: ChildProcess | null; leaseRunIds: Set; idleTimer: ReturnType | null; envFingerprint: string; } const runtimeServicesById = new Map(); const runtimeServicesByReuseKey = new Map(); const runtimeServiceLeasesByRun = new Map(); function stableStringify(value: unknown): string { if (Array.isArray(value)) { return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; } if (value && typeof value === "object") { const rec = value as Record; return `{${Object.keys(rec).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(rec[key])}`).join(",")}}`; } return JSON.stringify(value); } export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...baseEnv }; for (const key of Object.keys(env)) { if (key.startsWith("PAPERCLIP_")) { delete env[key]; } } delete env.DATABASE_URL; return env; } function stableRuntimeServiceId(input: { adapterType: string; runId: string; scopeType: RuntimeServiceRef["scopeType"]; scopeId: string | null; serviceName: string; reportId: string | null; providerRef: string | null; reuseKey: string | null; }) { if (input.reportId) return input.reportId; const digest = createHash("sha256") .update( stableStringify({ adapterType: input.adapterType, runId: input.runId, scopeType: input.scopeType, scopeId: input.scopeId, serviceName: input.serviceName, providerRef: input.providerRef, reuseKey: input.reuseKey, }), ) .digest("hex") .slice(0, 32); return `${input.adapterType}-${digest}`; } function toRuntimeServiceRef(record: RuntimeServiceRecord, overrides?: Partial): RuntimeServiceRef { return { id: record.id, companyId: record.companyId, projectId: record.projectId, projectWorkspaceId: record.projectWorkspaceId, executionWorkspaceId: record.executionWorkspaceId, issueId: record.issueId, serviceName: record.serviceName, status: record.status, lifecycle: record.lifecycle, scopeType: record.scopeType, scopeId: record.scopeId, reuseKey: record.reuseKey, command: record.command, cwd: record.cwd, port: record.port, url: record.url, provider: record.provider, providerRef: record.providerRef, ownerAgentId: record.ownerAgentId, startedByRunId: record.startedByRunId, lastUsedAt: record.lastUsedAt, startedAt: record.startedAt, stoppedAt: record.stoppedAt, stopPolicy: record.stopPolicy, healthStatus: record.healthStatus, reused: record.reused, ...overrides, }; } function sanitizeSlugPart(value: string | null | undefined, fallback: string): string { const raw = (value ?? "").trim().toLowerCase(); const normalized = raw .replace(/[^a-z0-9/_-]+/g, "-") .replace(/-+/g, "-") .replace(/^[-/]+|[-/]+$/g, ""); return normalized.length > 0 ? normalized : fallback; } function renderWorkspaceTemplate(template: string, input: { issue: ExecutionWorkspaceIssueRef | null; agent: ExecutionWorkspaceAgentRef; projectId: string | null; repoRef: string | null; }) { const issueIdentifier = input.issue?.identifier ?? input.issue?.id ?? "issue"; const slug = sanitizeSlugPart(input.issue?.title, sanitizeSlugPart(issueIdentifier, "issue")); return renderTemplate(template, { issue: { id: input.issue?.id ?? "", identifier: input.issue?.identifier ?? "", title: input.issue?.title ?? "", }, agent: { id: input.agent.id, name: input.agent.name, }, project: { id: input.projectId ?? "", }, workspace: { repoRef: input.repoRef ?? "", }, slug, }); } function sanitizeBranchName(value: string): string { return value .trim() .replace(/[^A-Za-z0-9._/-]+/g, "-") .replace(/-+/g, "-") .replace(/^[-/.]+|[-/.]+$/g, "") .slice(0, 120) || "paperclip-work"; } function isAbsolutePath(value: string) { return path.isAbsolute(value) || value.startsWith("~"); } function resolveConfiguredPath(value: string, baseDir: string): string { if (isAbsolutePath(value)) { return resolveHomeAwarePath(value); } return path.resolve(baseDir, value); } function formatCommandForDisplay(command: string, args: string[]) { return [command, ...args] .map((part) => (/^[A-Za-z0-9_./:-]+$/.test(part) ? part : JSON.stringify(part))) .join(" "); } async function executeProcess(input: { command: string; args: string[]; cwd: string; env?: NodeJS.ProcessEnv; }): Promise<{ stdout: string; stderr: string; code: number | null }> { const proc = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve, reject) => { const child = spawn(input.command, input.args, { cwd: input.cwd, stdio: ["ignore", "pipe", "pipe"], env: input.env ?? process.env, }); let stdout = ""; let stderr = ""; child.stdout?.on("data", (chunk) => { stdout += String(chunk); }); child.stderr?.on("data", (chunk) => { stderr += String(chunk); }); child.on("error", reject); child.on("close", (code) => resolve({ stdout, stderr, code })); }); return proc; } async function runGit(args: string[], cwd: string): Promise { const proc = await executeProcess({ command: "git", args, cwd, }); if (proc.code !== 0) { throw new Error(proc.stderr.trim() || proc.stdout.trim() || `git ${args.join(" ")} failed`); } return proc.stdout.trim(); } function gitErrorIncludes(error: unknown, needle: string) { const message = error instanceof Error ? error.message : String(error); return message.toLowerCase().includes(needle.toLowerCase()); } async function directoryExists(value: string) { return fs.stat(value).then((stats) => stats.isDirectory()).catch(() => false); } function terminateChildProcess(child: ChildProcess) { if (!child.pid) return; if (process.platform !== "win32") { try { process.kill(-child.pid, "SIGTERM"); return; } catch { // Fall through to the direct child kill. } } if (!child.killed) { child.kill("SIGTERM"); } } function buildWorkspaceCommandEnv(input: { base: ExecutionWorkspaceInput; repoRoot: string; worktreePath: string; branchName: string; issue: ExecutionWorkspaceIssueRef | null; agent: ExecutionWorkspaceAgentRef; created: boolean; }) { const env: NodeJS.ProcessEnv = { ...process.env }; env.PAPERCLIP_WORKSPACE_CWD = input.worktreePath; env.PAPERCLIP_WORKSPACE_PATH = input.worktreePath; env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = input.worktreePath; env.PAPERCLIP_WORKSPACE_BRANCH = input.branchName; env.PAPERCLIP_WORKSPACE_BASE_CWD = input.base.baseCwd; env.PAPERCLIP_WORKSPACE_REPO_ROOT = input.repoRoot; env.PAPERCLIP_WORKSPACE_SOURCE = input.base.source; env.PAPERCLIP_WORKSPACE_REPO_REF = input.base.repoRef ?? ""; env.PAPERCLIP_WORKSPACE_REPO_URL = input.base.repoUrl ?? ""; env.PAPERCLIP_WORKSPACE_CREATED = input.created ? "true" : "false"; env.PAPERCLIP_PROJECT_ID = input.base.projectId ?? ""; env.PAPERCLIP_PROJECT_WORKSPACE_ID = input.base.workspaceId ?? ""; env.PAPERCLIP_AGENT_ID = input.agent.id; env.PAPERCLIP_AGENT_NAME = input.agent.name; env.PAPERCLIP_COMPANY_ID = input.agent.companyId; env.PAPERCLIP_ISSUE_ID = input.issue?.id ?? ""; env.PAPERCLIP_ISSUE_IDENTIFIER = input.issue?.identifier ?? ""; env.PAPERCLIP_ISSUE_TITLE = input.issue?.title ?? ""; return env; } async function runWorkspaceCommand(input: { command: string; cwd: string; env: NodeJS.ProcessEnv; label: string; }) { const shell = process.env.SHELL?.trim() || "/bin/sh"; const proc = await executeProcess({ command: shell, args: ["-c", input.command], cwd: input.cwd, env: input.env, }); if (proc.code === 0) return; const details = [proc.stderr.trim(), proc.stdout.trim()].filter(Boolean).join("\n"); throw new Error( details.length > 0 ? `${input.label} failed: ${details}` : `${input.label} failed with exit code ${proc.code ?? -1}`, ); } async function recordGitOperation( recorder: WorkspaceOperationRecorder | null | undefined, input: { phase: "worktree_prepare" | "worktree_cleanup"; args: string[]; cwd: string; metadata?: Record | null; successMessage?: string | null; failureLabel?: string | null; }, ): Promise { if (!recorder) { return runGit(input.args, input.cwd); } let stdout = ""; let stderr = ""; let code: number | null = null; await recorder.recordOperation({ phase: input.phase, command: formatCommandForDisplay("git", input.args), cwd: input.cwd, metadata: input.metadata ?? null, run: async () => { const result = await executeProcess({ command: "git", args: input.args, cwd: input.cwd, }); stdout = result.stdout; stderr = result.stderr; code = result.code; return { status: result.code === 0 ? "succeeded" : "failed", exitCode: result.code, stdout: result.stdout, stderr: result.stderr, system: result.code === 0 ? input.successMessage ?? null : null, }; }, }); if (code !== 0) { const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n"); throw new Error( details.length > 0 ? `${input.failureLabel ?? `git ${input.args.join(" ")}`} failed: ${details}` : `${input.failureLabel ?? `git ${input.args.join(" ")}`} failed with exit code ${code ?? -1}`, ); } return stdout.trim(); } async function recordWorkspaceCommandOperation( recorder: WorkspaceOperationRecorder | null | undefined, input: { phase: "workspace_provision" | "workspace_teardown"; command: string; cwd: string; env: NodeJS.ProcessEnv; label: string; metadata?: Record | null; successMessage?: string | null; }, ) { if (!recorder) { await runWorkspaceCommand(input); return; } let stdout = ""; let stderr = ""; let code: number | null = null; await recorder.recordOperation({ phase: input.phase, command: input.command, cwd: input.cwd, metadata: input.metadata ?? null, run: async () => { const shell = process.env.SHELL?.trim() || "/bin/sh"; const result = await executeProcess({ command: shell, args: ["-c", input.command], cwd: input.cwd, env: input.env, }); stdout = result.stdout; stderr = result.stderr; code = result.code; return { status: result.code === 0 ? "succeeded" : "failed", exitCode: result.code, stdout: result.stdout, stderr: result.stderr, system: result.code === 0 ? input.successMessage ?? null : null, }; }, }); if (code === 0) return; const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n"); throw new Error( details.length > 0 ? `${input.label} failed: ${details}` : `${input.label} failed with exit code ${code ?? -1}`, ); } async function provisionExecutionWorktree(input: { strategy: Record; base: ExecutionWorkspaceInput; repoRoot: string; worktreePath: string; branchName: string; issue: ExecutionWorkspaceIssueRef | null; agent: ExecutionWorkspaceAgentRef; created: boolean; recorder?: WorkspaceOperationRecorder | null; }) { const provisionCommand = asString(input.strategy.provisionCommand, "").trim(); if (!provisionCommand) return; await recordWorkspaceCommandOperation(input.recorder, { phase: "workspace_provision", command: provisionCommand, cwd: input.worktreePath, env: buildWorkspaceCommandEnv({ base: input.base, repoRoot: input.repoRoot, worktreePath: input.worktreePath, branchName: input.branchName, issue: input.issue, agent: input.agent, created: input.created, }), label: `Execution workspace provision command "${provisionCommand}"`, metadata: { repoRoot: input.repoRoot, worktreePath: input.worktreePath, branchName: input.branchName, created: input.created, }, successMessage: `Provisioned workspace at ${input.worktreePath}\n`, }); } function buildExecutionWorkspaceCleanupEnv(input: { workspace: { cwd: string | null; providerRef: string | null; branchName: string | null; repoUrl: string | null; baseRef: string | null; projectId: string | null; projectWorkspaceId: string | null; sourceIssueId: string | null; }; projectWorkspaceCwd?: string | null; }) { const env: NodeJS.ProcessEnv = sanitizeRuntimeServiceBaseEnv(process.env); env.PAPERCLIP_WORKSPACE_CWD = input.workspace.cwd ?? ""; env.PAPERCLIP_WORKSPACE_PATH = input.workspace.cwd ?? ""; env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = input.workspace.providerRef ?? input.workspace.cwd ?? ""; env.PAPERCLIP_WORKSPACE_BRANCH = input.workspace.branchName ?? ""; env.PAPERCLIP_WORKSPACE_BASE_CWD = input.projectWorkspaceCwd ?? ""; env.PAPERCLIP_WORKSPACE_REPO_ROOT = input.projectWorkspaceCwd ?? ""; env.PAPERCLIP_WORKSPACE_REPO_URL = input.workspace.repoUrl ?? ""; env.PAPERCLIP_WORKSPACE_REPO_REF = input.workspace.baseRef ?? ""; env.PAPERCLIP_PROJECT_ID = input.workspace.projectId ?? ""; env.PAPERCLIP_PROJECT_WORKSPACE_ID = input.workspace.projectWorkspaceId ?? ""; env.PAPERCLIP_ISSUE_ID = input.workspace.sourceIssueId ?? ""; return env; } async function resolveGitRepoRootForWorkspaceCleanup( worktreePath: string, projectWorkspaceCwd: string | null, ): Promise { if (projectWorkspaceCwd) { const resolvedProjectWorkspaceCwd = path.resolve(projectWorkspaceCwd); const gitDir = await runGit(["rev-parse", "--git-common-dir"], resolvedProjectWorkspaceCwd) .catch(() => null); if (gitDir) { const resolvedGitDir = path.resolve(resolvedProjectWorkspaceCwd, gitDir); return path.dirname(resolvedGitDir); } } const gitDir = await runGit(["rev-parse", "--git-common-dir"], worktreePath).catch(() => null); if (!gitDir) return null; const resolvedGitDir = path.resolve(worktreePath, gitDir); return path.dirname(resolvedGitDir); } export async function realizeExecutionWorkspace(input: { base: ExecutionWorkspaceInput; config: Record; issue: ExecutionWorkspaceIssueRef | null; agent: ExecutionWorkspaceAgentRef; recorder?: WorkspaceOperationRecorder | null; }): Promise { const rawStrategy = parseObject(input.config.workspaceStrategy); const strategyType = asString(rawStrategy.type, "project_primary"); if (strategyType !== "git_worktree") { return { ...input.base, strategy: "project_primary", cwd: input.base.baseCwd, branchName: null, worktreePath: null, warnings: [], created: false, }; } const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd); const branchTemplate = asString(rawStrategy.branchTemplate, "{{issue.identifier}}-{{slug}}"); const renderedBranch = renderWorkspaceTemplate(branchTemplate, { issue: input.issue, agent: input.agent, projectId: input.base.projectId, repoRef: input.base.repoRef, }); const branchName = sanitizeBranchName(renderedBranch); const configuredParentDir = asString(rawStrategy.worktreeParentDir, ""); const worktreeParentDir = configuredParentDir ? resolveConfiguredPath(configuredParentDir, repoRoot) : path.join(repoRoot, ".paperclip", "worktrees"); const worktreePath = path.join(worktreeParentDir, branchName); const baseRef = asString(rawStrategy.baseRef, input.base.repoRef ?? "HEAD"); await fs.mkdir(worktreeParentDir, { recursive: true }); const existingWorktree = await directoryExists(worktreePath); if (existingWorktree) { const existingGitDir = await runGit(["rev-parse", "--git-dir"], worktreePath).catch(() => null); if (existingGitDir) { if (input.recorder) { await input.recorder.recordOperation({ phase: "worktree_prepare", cwd: repoRoot, metadata: { repoRoot, worktreePath, branchName, baseRef, created: false, reused: true, }, run: async () => ({ status: "succeeded", exitCode: 0, system: `Reused existing git worktree at ${worktreePath}\n`, }), }); } await provisionExecutionWorktree({ strategy: rawStrategy, base: input.base, repoRoot, worktreePath, branchName, issue: input.issue, agent: input.agent, created: false, recorder: input.recorder ?? null, }); return { ...input.base, strategy: "git_worktree", cwd: worktreePath, branchName, worktreePath, warnings: [], created: false, }; } throw new Error(`Configured worktree path "${worktreePath}" already exists and is not a git worktree.`); } try { await recordGitOperation(input.recorder, { phase: "worktree_prepare", args: ["worktree", "add", "-b", branchName, worktreePath, baseRef], cwd: repoRoot, metadata: { repoRoot, worktreePath, branchName, baseRef, created: true, }, successMessage: `Created git worktree at ${worktreePath}\n`, failureLabel: `git worktree add ${worktreePath}`, }); } catch (error) { if (!gitErrorIncludes(error, "already exists")) { throw error; } await recordGitOperation(input.recorder, { phase: "worktree_prepare", args: ["worktree", "add", worktreePath, branchName], cwd: repoRoot, metadata: { repoRoot, worktreePath, branchName, baseRef, created: false, reusedExistingBranch: true, }, successMessage: `Attached existing branch ${branchName} at ${worktreePath}\n`, failureLabel: `git worktree add ${worktreePath}`, }); } await provisionExecutionWorktree({ strategy: rawStrategy, base: input.base, repoRoot, worktreePath, branchName, issue: input.issue, agent: input.agent, created: true, recorder: input.recorder ?? null, }); return { ...input.base, strategy: "git_worktree", cwd: worktreePath, branchName, worktreePath, warnings: [], created: true, }; } export async function cleanupExecutionWorkspaceArtifacts(input: { workspace: { id: string; cwd: string | null; providerType: string; providerRef: string | null; branchName: string | null; repoUrl: string | null; baseRef: string | null; projectId: string | null; projectWorkspaceId: string | null; sourceIssueId: string | null; metadata?: Record | null; }; projectWorkspace?: { cwd: string | null; cleanupCommand: string | null; } | null; teardownCommand?: string | null; recorder?: WorkspaceOperationRecorder | null; }) { const warnings: string[] = []; const workspacePath = input.workspace.providerRef ?? input.workspace.cwd; const cleanupEnv = buildExecutionWorkspaceCleanupEnv({ workspace: input.workspace, projectWorkspaceCwd: input.projectWorkspace?.cwd ?? null, }); const createdByRuntime = input.workspace.metadata?.createdByRuntime === true; const cleanupCommands = [ input.projectWorkspace?.cleanupCommand ?? null, input.teardownCommand ?? null, ] .map((value) => asString(value, "").trim()) .filter(Boolean); for (const command of cleanupCommands) { try { await recordWorkspaceCommandOperation(input.recorder, { phase: "workspace_teardown", command, cwd: workspacePath ?? input.projectWorkspace?.cwd ?? process.cwd(), env: cleanupEnv, label: `Execution workspace cleanup command "${command}"`, metadata: { workspaceId: input.workspace.id, workspacePath, branchName: input.workspace.branchName, providerType: input.workspace.providerType, }, successMessage: `Completed cleanup command "${command}"\n`, }); } catch (err) { warnings.push(err instanceof Error ? err.message : String(err)); } } if (input.workspace.providerType === "git_worktree" && workspacePath) { const repoRoot = await resolveGitRepoRootForWorkspaceCleanup( workspacePath, input.projectWorkspace?.cwd ?? null, ); const worktreeExists = await directoryExists(workspacePath); if (worktreeExists) { if (!repoRoot) { warnings.push(`Could not resolve git repo root for "${workspacePath}".`); } else { try { await recordGitOperation(input.recorder, { phase: "worktree_cleanup", args: ["worktree", "remove", "--force", workspacePath], cwd: repoRoot, metadata: { workspaceId: input.workspace.id, workspacePath, branchName: input.workspace.branchName, cleanupAction: "worktree_remove", }, successMessage: `Removed git worktree ${workspacePath}\n`, failureLabel: `git worktree remove ${workspacePath}`, }); } catch (err) { warnings.push(err instanceof Error ? err.message : String(err)); } } } if (createdByRuntime && input.workspace.branchName) { if (!repoRoot) { warnings.push(`Could not resolve git repo root to delete branch "${input.workspace.branchName}".`); } else { try { await recordGitOperation(input.recorder, { phase: "worktree_cleanup", args: ["branch", "-d", input.workspace.branchName], cwd: repoRoot, metadata: { workspaceId: input.workspace.id, workspacePath, branchName: input.workspace.branchName, cleanupAction: "branch_delete", }, successMessage: `Deleted branch ${input.workspace.branchName}\n`, failureLabel: `git branch -d ${input.workspace.branchName}`, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); warnings.push(`Skipped deleting branch "${input.workspace.branchName}": ${message}`); } } } } else if (input.workspace.providerType === "local_fs" && createdByRuntime && workspacePath) { const projectWorkspaceCwd = input.projectWorkspace?.cwd ? path.resolve(input.projectWorkspace.cwd) : null; const resolvedWorkspacePath = path.resolve(workspacePath); const containsProjectWorkspace = projectWorkspaceCwd ? ( resolvedWorkspacePath === projectWorkspaceCwd || projectWorkspaceCwd.startsWith(`${resolvedWorkspacePath}${path.sep}`) ) : false; if (containsProjectWorkspace) { warnings.push(`Refusing to remove path "${workspacePath}" because it contains the project workspace.`); } else { await fs.rm(resolvedWorkspacePath, { recursive: true, force: true }); if (input.recorder) { await input.recorder.recordOperation({ phase: "workspace_teardown", cwd: projectWorkspaceCwd ?? process.cwd(), metadata: { workspaceId: input.workspace.id, workspacePath: resolvedWorkspacePath, cleanupAction: "remove_local_fs", }, run: async () => ({ status: "succeeded", exitCode: 0, system: `Removed local workspace directory ${resolvedWorkspacePath}\n`, }), }); } } } const cleaned = !workspacePath || !(await directoryExists(workspacePath)); return { cleanedPath: workspacePath, cleaned, warnings, }; } async function allocatePort(): Promise { return await new Promise((resolve, reject) => { const server = net.createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); server.close((err) => { if (err) { reject(err); return; } if (!address || typeof address === "string") { reject(new Error("Failed to allocate port")); return; } resolve(address.port); }); }); server.on("error", reject); }); } function buildTemplateData(input: { workspace: RealizedExecutionWorkspace; agent: ExecutionWorkspaceAgentRef; issue: ExecutionWorkspaceIssueRef | null; adapterEnv: Record; port: number | null; }) { return { workspace: { cwd: input.workspace.cwd, branchName: input.workspace.branchName ?? "", worktreePath: input.workspace.worktreePath ?? "", repoUrl: input.workspace.repoUrl ?? "", repoRef: input.workspace.repoRef ?? "", env: input.adapterEnv, }, issue: { id: input.issue?.id ?? "", identifier: input.issue?.identifier ?? "", title: input.issue?.title ?? "", }, agent: { id: input.agent.id, name: input.agent.name, }, port: input.port ?? "", }; } function resolveServiceScopeId(input: { service: Record; workspace: RealizedExecutionWorkspace; executionWorkspaceId?: string | null; issue: ExecutionWorkspaceIssueRef | null; runId: string; agent: ExecutionWorkspaceAgentRef; }): { scopeType: "project_workspace" | "execution_workspace" | "run" | "agent"; scopeId: string | null; } { const scopeTypeRaw = asString(input.service.reuseScope, input.service.lifecycle === "shared" ? "project_workspace" : "run"); const scopeType = scopeTypeRaw === "project_workspace" || scopeTypeRaw === "execution_workspace" || scopeTypeRaw === "agent" ? scopeTypeRaw : "run"; if (scopeType === "project_workspace") return { scopeType, scopeId: input.workspace.workspaceId ?? input.workspace.projectId }; if (scopeType === "execution_workspace") { return { scopeType, scopeId: input.executionWorkspaceId ?? input.workspace.cwd }; } if (scopeType === "agent") return { scopeType, scopeId: input.agent.id }; return { scopeType: "run" as const, scopeId: input.runId }; } async function waitForReadiness(input: { service: Record; url: string | null; }) { const readiness = parseObject(input.service.readiness); const readinessType = asString(readiness.type, ""); if (readinessType !== "http" || !input.url) return; const timeoutSec = Math.max(1, asNumber(readiness.timeoutSec, 30)); const intervalMs = Math.max(100, asNumber(readiness.intervalMs, 500)); const deadline = Date.now() + timeoutSec * 1000; let lastError = "service did not become ready"; while (Date.now() < deadline) { try { const response = await fetch(input.url); if (response.ok) return; lastError = `received HTTP ${response.status}`; } catch (err) { lastError = err instanceof Error ? err.message : String(err); } await delay(intervalMs); } throw new Error(`Readiness check failed for ${input.url}: ${lastError}`); } function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeof workspaceRuntimeServices.$inferInsert { return { id: record.id, companyId: record.companyId, projectId: record.projectId, projectWorkspaceId: record.projectWorkspaceId, executionWorkspaceId: record.executionWorkspaceId, issueId: record.issueId, scopeType: record.scopeType, scopeId: record.scopeId, serviceName: record.serviceName, status: record.status, lifecycle: record.lifecycle, reuseKey: record.reuseKey, command: record.command, cwd: record.cwd, port: record.port, url: record.url, provider: record.provider, providerRef: record.providerRef, ownerAgentId: record.ownerAgentId, startedByRunId: record.startedByRunId, lastUsedAt: new Date(record.lastUsedAt), startedAt: new Date(record.startedAt), stoppedAt: record.stoppedAt ? new Date(record.stoppedAt) : null, stopPolicy: record.stopPolicy, healthStatus: record.healthStatus, updatedAt: new Date(), }; } async function persistRuntimeServiceRecord(db: Db | undefined, record: RuntimeServiceRecord) { if (!db) return; const values = toPersistedWorkspaceRuntimeService(record); await db .insert(workspaceRuntimeServices) .values(values) .onConflictDoUpdate({ target: workspaceRuntimeServices.id, set: { projectId: values.projectId, projectWorkspaceId: values.projectWorkspaceId, executionWorkspaceId: values.executionWorkspaceId, issueId: values.issueId, scopeType: values.scopeType, scopeId: values.scopeId, serviceName: values.serviceName, status: values.status, lifecycle: values.lifecycle, reuseKey: values.reuseKey, command: values.command, cwd: values.cwd, port: values.port, url: values.url, provider: values.provider, providerRef: values.providerRef, ownerAgentId: values.ownerAgentId, startedByRunId: values.startedByRunId, lastUsedAt: values.lastUsedAt, startedAt: values.startedAt, stoppedAt: values.stoppedAt, stopPolicy: values.stopPolicy, healthStatus: values.healthStatus, updatedAt: values.updatedAt, }, }); } function clearIdleTimer(record: RuntimeServiceRecord) { if (!record.idleTimer) return; clearTimeout(record.idleTimer); record.idleTimer = null; } export function normalizeAdapterManagedRuntimeServices(input: { adapterType: string; runId: string; agent: ExecutionWorkspaceAgentRef; issue: ExecutionWorkspaceIssueRef | null; workspace: RealizedExecutionWorkspace; executionWorkspaceId?: string | null; reports: AdapterRuntimeServiceReport[]; now?: Date; }): RuntimeServiceRef[] { const nowIso = (input.now ?? new Date()).toISOString(); return input.reports.map((report) => { const scopeType = report.scopeType ?? "run"; const scopeId = report.scopeId ?? (scopeType === "project_workspace" ? input.workspace.workspaceId : scopeType === "execution_workspace" ? input.executionWorkspaceId ?? input.workspace.cwd : scopeType === "agent" ? input.agent.id : input.runId) ?? null; const serviceName = asString(report.serviceName, "").trim() || "service"; const status = report.status ?? "running"; const lifecycle = report.lifecycle ?? "ephemeral"; const healthStatus = report.healthStatus ?? (status === "running" ? "healthy" : status === "failed" ? "unhealthy" : "unknown"); return { id: stableRuntimeServiceId({ adapterType: input.adapterType, runId: input.runId, scopeType, scopeId, serviceName, reportId: report.id ?? null, providerRef: report.providerRef ?? null, reuseKey: report.reuseKey ?? null, }), companyId: input.agent.companyId, projectId: report.projectId ?? input.workspace.projectId, projectWorkspaceId: report.projectWorkspaceId ?? input.workspace.workspaceId, executionWorkspaceId: input.executionWorkspaceId ?? null, issueId: report.issueId ?? input.issue?.id ?? null, serviceName, status, lifecycle, scopeType, scopeId, reuseKey: report.reuseKey ?? null, command: report.command ?? null, cwd: report.cwd ?? null, port: report.port ?? null, url: report.url ?? null, provider: "adapter_managed", providerRef: report.providerRef ?? null, ownerAgentId: report.ownerAgentId ?? input.agent.id, startedByRunId: input.runId, lastUsedAt: nowIso, startedAt: nowIso, stoppedAt: status === "running" || status === "starting" ? null : nowIso, stopPolicy: report.stopPolicy ?? null, healthStatus, reused: false, }; }); } async function startLocalRuntimeService(input: { db?: Db; runId: string; agent: ExecutionWorkspaceAgentRef; issue: ExecutionWorkspaceIssueRef | null; workspace: RealizedExecutionWorkspace; executionWorkspaceId?: string | null; adapterEnv: Record; service: Record; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; reuseKey: string | null; scopeType: "project_workspace" | "execution_workspace" | "run" | "agent"; scopeId: string | null; }): Promise { const serviceName = asString(input.service.name, "service"); const lifecycle = asString(input.service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared"; const command = asString(input.service.command, ""); if (!command) throw new Error(`Runtime service "${serviceName}" is missing command`); const serviceCwdTemplate = asString(input.service.cwd, "."); const portConfig = parseObject(input.service.port); const port = asString(portConfig.type, "") === "auto" ? await allocatePort() : null; const envConfig = parseObject(input.service.env); const templateData = buildTemplateData({ workspace: input.workspace, agent: input.agent, issue: input.issue, adapterEnv: input.adapterEnv, port, }); const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd); const env: Record = { ...sanitizeRuntimeServiceBaseEnv(process.env), ...input.adapterEnv, } as Record; for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") { env[key] = renderTemplate(value, templateData); } } if (port) { const portEnvKey = asString(portConfig.envKey, "PORT"); env[portEnvKey] = String(port); } const shell = process.env.SHELL?.trim() || "/bin/sh"; const child = spawn(shell, ["-lc", command], { cwd: serviceCwd, env, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], }); let stderrExcerpt = ""; let stdoutExcerpt = ""; child.stdout?.on("data", async (chunk) => { const text = String(chunk); stdoutExcerpt = (stdoutExcerpt + text).slice(-4096); if (input.onLog) await input.onLog("stdout", `[service:${serviceName}] ${text}`); }); child.stderr?.on("data", async (chunk) => { const text = String(chunk); stderrExcerpt = (stderrExcerpt + text).slice(-4096); if (input.onLog) await input.onLog("stderr", `[service:${serviceName}] ${text}`); }); const expose = parseObject(input.service.expose); const readiness = parseObject(input.service.readiness); const urlTemplate = asString(expose.urlTemplate, "") || asString(readiness.urlTemplate, ""); const url = urlTemplate ? renderTemplate(urlTemplate, templateData) : null; try { await waitForReadiness({ service: input.service, url }); } catch (err) { terminateChildProcess(child); throw new Error( `Failed to start runtime service "${serviceName}": ${err instanceof Error ? err.message : String(err)}${stderrExcerpt ? ` | stderr: ${stderrExcerpt.trim()}` : ""}`, ); } const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex"); return { id: randomUUID(), companyId: input.agent.companyId, projectId: input.workspace.projectId, projectWorkspaceId: input.workspace.workspaceId, executionWorkspaceId: input.executionWorkspaceId ?? null, issueId: input.issue?.id ?? null, serviceName, status: "running", lifecycle, scopeType: input.scopeType, scopeId: input.scopeId, reuseKey: input.reuseKey, command, cwd: serviceCwd, port, url, provider: "local_process", providerRef: child.pid ? String(child.pid) : null, ownerAgentId: input.agent.id, startedByRunId: input.runId, lastUsedAt: new Date().toISOString(), startedAt: new Date().toISOString(), stoppedAt: null, stopPolicy: parseObject(input.service.stopPolicy), healthStatus: "healthy", reused: false, db: input.db, child, leaseRunIds: new Set([input.runId]), idleTimer: null, envFingerprint, }; } function scheduleIdleStop(record: RuntimeServiceRecord) { clearIdleTimer(record); const stopType = asString(record.stopPolicy?.type, "manual"); if (stopType !== "idle_timeout") return; const idleSeconds = Math.max(1, asNumber(record.stopPolicy?.idleSeconds, 1800)); record.idleTimer = setTimeout(() => { stopRuntimeService(record.id).catch(() => undefined); }, idleSeconds * 1000); } async function stopRuntimeService(serviceId: string) { const record = runtimeServicesById.get(serviceId); if (!record) return; clearIdleTimer(record); record.status = "stopped"; record.lastUsedAt = new Date().toISOString(); record.stoppedAt = new Date().toISOString(); if (record.child && record.child.pid) { terminateChildProcess(record.child); } runtimeServicesById.delete(serviceId); if (record.reuseKey) { runtimeServicesByReuseKey.delete(record.reuseKey); } await persistRuntimeServiceRecord(record.db, record); } async function markPersistedRuntimeServicesStoppedForExecutionWorkspace(input: { db: Db; executionWorkspaceId: string; }) { const now = new Date(); await input.db .update(workspaceRuntimeServices) .set({ status: "stopped", healthStatus: "unknown", stoppedAt: now, lastUsedAt: now, updatedAt: now, }) .where( and( eq(workspaceRuntimeServices.executionWorkspaceId, input.executionWorkspaceId), inArray(workspaceRuntimeServices.status, ["starting", "running"]), ), ); } function registerRuntimeService(db: Db | undefined, record: RuntimeServiceRecord) { record.db = db; runtimeServicesById.set(record.id, record); if (record.reuseKey) { runtimeServicesByReuseKey.set(record.reuseKey, record.id); } record.child?.on("exit", (code, signal) => { const current = runtimeServicesById.get(record.id); if (!current) return; clearIdleTimer(current); current.status = code === 0 || signal === "SIGTERM" ? "stopped" : "failed"; current.healthStatus = current.status === "failed" ? "unhealthy" : "unknown"; current.lastUsedAt = new Date().toISOString(); current.stoppedAt = new Date().toISOString(); runtimeServicesById.delete(current.id); if (current.reuseKey && runtimeServicesByReuseKey.get(current.reuseKey) === current.id) { runtimeServicesByReuseKey.delete(current.reuseKey); } void persistRuntimeServiceRecord(db, current); }); } export async function ensureRuntimeServicesForRun(input: { db?: Db; runId: string; agent: ExecutionWorkspaceAgentRef; issue: ExecutionWorkspaceIssueRef | null; workspace: RealizedExecutionWorkspace; executionWorkspaceId?: string | null; config: Record; adapterEnv: Record; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; }): Promise { const runtime = parseObject(input.config.workspaceRuntime); const rawServices = Array.isArray(runtime.services) ? runtime.services.filter((entry): entry is Record => typeof entry === "object" && entry !== null) : []; const acquiredServiceIds: string[] = []; const refs: RuntimeServiceRef[] = []; runtimeServiceLeasesByRun.set(input.runId, acquiredServiceIds); try { for (const service of rawServices) { const lifecycle = asString(service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared"; const { scopeType, scopeId } = resolveServiceScopeId({ service, workspace: input.workspace, executionWorkspaceId: input.executionWorkspaceId, issue: input.issue, runId: input.runId, agent: input.agent, }); const envConfig = parseObject(service.env); const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex"); const serviceName = asString(service.name, "service"); const reuseKey = lifecycle === "shared" ? [scopeType, scopeId ?? "", serviceName, envFingerprint].join(":") : null; if (reuseKey) { const existingId = runtimeServicesByReuseKey.get(reuseKey); const existing = existingId ? runtimeServicesById.get(existingId) : null; if (existing && existing.status === "running") { existing.leaseRunIds.add(input.runId); existing.lastUsedAt = new Date().toISOString(); existing.stoppedAt = null; clearIdleTimer(existing); await persistRuntimeServiceRecord(input.db, existing); acquiredServiceIds.push(existing.id); refs.push(toRuntimeServiceRef(existing, { reused: true })); continue; } } const record = await startLocalRuntimeService({ db: input.db, runId: input.runId, agent: input.agent, issue: input.issue, workspace: input.workspace, executionWorkspaceId: input.executionWorkspaceId, adapterEnv: input.adapterEnv, service, onLog: input.onLog, reuseKey, scopeType, scopeId, }); registerRuntimeService(input.db, record); await persistRuntimeServiceRecord(input.db, record); acquiredServiceIds.push(record.id); refs.push(toRuntimeServiceRef(record)); } } catch (err) { await releaseRuntimeServicesForRun(input.runId); throw err; } return refs; } export async function releaseRuntimeServicesForRun(runId: string) { const acquired = runtimeServiceLeasesByRun.get(runId) ?? []; runtimeServiceLeasesByRun.delete(runId); for (const serviceId of acquired) { const record = runtimeServicesById.get(serviceId); if (!record) continue; record.leaseRunIds.delete(runId); record.lastUsedAt = new Date().toISOString(); const stopType = asString(record.stopPolicy?.type, record.lifecycle === "ephemeral" ? "on_run_finish" : "manual"); await persistRuntimeServiceRecord(record.db, record); if (record.leaseRunIds.size === 0) { if (record.lifecycle === "ephemeral" || stopType === "on_run_finish") { await stopRuntimeService(serviceId); continue; } scheduleIdleStop(record); } } } export async function stopRuntimeServicesForExecutionWorkspace(input: { db?: Db; executionWorkspaceId: string; workspaceCwd?: string | null; }) { const normalizedWorkspaceCwd = input.workspaceCwd ? path.resolve(input.workspaceCwd) : null; const matchingServiceIds = Array.from(runtimeServicesById.values()) .filter((record) => { if (record.executionWorkspaceId === input.executionWorkspaceId) return true; if (!normalizedWorkspaceCwd || !record.cwd) return false; const resolvedCwd = path.resolve(record.cwd); return ( resolvedCwd === normalizedWorkspaceCwd || resolvedCwd.startsWith(`${normalizedWorkspaceCwd}${path.sep}`) ); }) .map((record) => record.id); for (const serviceId of matchingServiceIds) { await stopRuntimeService(serviceId); } if (input.db) { await markPersistedRuntimeServicesStoppedForExecutionWorkspace({ db: input.db, executionWorkspaceId: input.executionWorkspaceId, }); } } export async function listWorkspaceRuntimeServicesForProjectWorkspaces( db: Db, companyId: string, projectWorkspaceIds: string[], ) { if (projectWorkspaceIds.length === 0) return new Map(); const rows = await db .select() .from(workspaceRuntimeServices) .where( and( eq(workspaceRuntimeServices.companyId, companyId), inArray(workspaceRuntimeServices.projectWorkspaceId, projectWorkspaceIds), ), ) .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); const grouped = new Map(); for (const row of rows) { if (!row.projectWorkspaceId) continue; const existing = grouped.get(row.projectWorkspaceId); if (existing) existing.push(row); else grouped.set(row.projectWorkspaceId, [row]); } return grouped; } export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) { const staleRows = await db .select({ id: workspaceRuntimeServices.id }) .from(workspaceRuntimeServices) .where( and( eq(workspaceRuntimeServices.provider, "local_process"), inArray(workspaceRuntimeServices.status, ["starting", "running"]), ), ); if (staleRows.length === 0) return { reconciled: 0 }; const now = new Date(); await db .update(workspaceRuntimeServices) .set({ status: "stopped", healthStatus: "unknown", stoppedAt: now, lastUsedAt: now, updatedAt: now, }) .where( and( eq(workspaceRuntimeServices.provider, "local_process"), inArray(workspaceRuntimeServices.status, ["starting", "running"]), ), ); return { reconciled: staleRows.length }; } export async function persistAdapterManagedRuntimeServices(input: { db: Db; adapterType: string; runId: string; agent: ExecutionWorkspaceAgentRef; issue: ExecutionWorkspaceIssueRef | null; workspace: RealizedExecutionWorkspace; executionWorkspaceId?: string | null; reports: AdapterRuntimeServiceReport[]; }) { const refs = normalizeAdapterManagedRuntimeServices(input); if (refs.length === 0) return refs; const existingRows = await input.db .select() .from(workspaceRuntimeServices) .where(inArray(workspaceRuntimeServices.id, refs.map((ref) => ref.id))); const existingById = new Map(existingRows.map((row) => [row.id, row])); for (const ref of refs) { const existing = existingById.get(ref.id); const startedAt = existing?.startedAt ?? new Date(ref.startedAt); const createdAt = existing?.createdAt ?? new Date(); await input.db .insert(workspaceRuntimeServices) .values({ id: ref.id, companyId: ref.companyId, projectId: ref.projectId, projectWorkspaceId: ref.projectWorkspaceId, executionWorkspaceId: ref.executionWorkspaceId, issueId: ref.issueId, scopeType: ref.scopeType, scopeId: ref.scopeId, serviceName: ref.serviceName, status: ref.status, lifecycle: ref.lifecycle, reuseKey: ref.reuseKey, command: ref.command, cwd: ref.cwd, port: ref.port, url: ref.url, provider: ref.provider, providerRef: ref.providerRef, ownerAgentId: ref.ownerAgentId, startedByRunId: ref.startedByRunId, lastUsedAt: new Date(ref.lastUsedAt), startedAt, stoppedAt: ref.stoppedAt ? new Date(ref.stoppedAt) : null, stopPolicy: ref.stopPolicy, healthStatus: ref.healthStatus, createdAt, updatedAt: new Date(), }) .onConflictDoUpdate({ target: workspaceRuntimeServices.id, set: { projectId: ref.projectId, projectWorkspaceId: ref.projectWorkspaceId, executionWorkspaceId: ref.executionWorkspaceId, issueId: ref.issueId, scopeType: ref.scopeType, scopeId: ref.scopeId, serviceName: ref.serviceName, status: ref.status, lifecycle: ref.lifecycle, reuseKey: ref.reuseKey, command: ref.command, cwd: ref.cwd, port: ref.port, url: ref.url, provider: ref.provider, providerRef: ref.providerRef, ownerAgentId: ref.ownerAgentId, startedByRunId: ref.startedByRunId, lastUsedAt: new Date(ref.lastUsedAt), startedAt, stoppedAt: ref.stoppedAt ? new Date(ref.stoppedAt) : null, stopPolicy: ref.stopPolicy, healthStatus: ref.healthStatus, updatedAt: new Date(), }, }); } return refs; } export function buildWorkspaceReadyComment(input: { workspace: RealizedExecutionWorkspace; runtimeServices: RuntimeServiceRef[]; }) { const lines = ["## Workspace Ready", ""]; lines.push(`- Strategy: \`${input.workspace.strategy}\``); if (input.workspace.branchName) lines.push(`- Branch: \`${input.workspace.branchName}\``); lines.push(`- CWD: \`${input.workspace.cwd}\``); if (input.workspace.worktreePath && input.workspace.worktreePath !== input.workspace.cwd) { lines.push(`- Worktree: \`${input.workspace.worktreePath}\``); } for (const service of input.runtimeServices) { const detail = service.url ? `${service.serviceName}: ${service.url}` : `${service.serviceName}: running`; const suffix = service.reused ? " (reused)" : ""; lines.push(`- Service: ${detail}${suffix}`); } return lines.join("\n"); }