diff --git a/server/src/__tests__/app-hmr-port.test.ts b/server/src/__tests__/app-hmr-port.test.ts new file mode 100644 index 00000000..2f25d3ab --- /dev/null +++ b/server/src/__tests__/app-hmr-port.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { resolveViteHmrPort } from "../app.ts"; + +describe("resolveViteHmrPort", () => { + it("uses serverPort + 10000 when the result stays in range", () => { + expect(resolveViteHmrPort(3100)).toBe(13_100); + expect(resolveViteHmrPort(55_535)).toBe(65_535); + }); + + it("falls back below the server port when adding 10000 would overflow", () => { + expect(resolveViteHmrPort(55_536)).toBe(45_536); + expect(resolveViteHmrPort(63_000)).toBe(53_000); + }); + + it("never returns a privileged or invalid port", () => { + expect(resolveViteHmrPort(65_535)).toBe(55_535); + expect(resolveViteHmrPort(9_000)).toBe(19_000); + }); +}); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 650556d1..acb58227 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; import { + prioritizeProjectWorkspaceCandidatesForRun, resolveRuntimeSessionParamsForWorkspace, shouldResetTaskSessionForWake, type ResolvedWorkspaceForRun, @@ -141,3 +142,39 @@ describe("shouldResetTaskSessionForWake", () => { ).toBe(false); }); }); + +describe("prioritizeProjectWorkspaceCandidatesForRun", () => { + it("moves the explicitly selected workspace to the front", () => { + const rows = [ + { id: "workspace-1", cwd: "/tmp/one" }, + { id: "workspace-2", cwd: "/tmp/two" }, + { id: "workspace-3", cwd: "/tmp/three" }, + ]; + + expect( + prioritizeProjectWorkspaceCandidatesForRun(rows, "workspace-2").map((row) => row.id), + ).toEqual(["workspace-2", "workspace-1", "workspace-3"]); + }); + + it("keeps the original order when no preferred workspace is selected", () => { + const rows = [ + { id: "workspace-1" }, + { id: "workspace-2" }, + ]; + + expect( + prioritizeProjectWorkspaceCandidatesForRun(rows, null).map((row) => row.id), + ).toEqual(["workspace-1", "workspace-2"]); + }); + + it("keeps the original order when the selected workspace is missing", () => { + const rows = [ + { id: "workspace-1" }, + { id: "workspace-2" }, + ]; + + expect( + prioritizeProjectWorkspaceCandidatesForRun(rows, "workspace-9").map((row) => row.id), + ).toEqual(["workspace-1", "workspace-2"]); + }); +}); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index ea01c1b9..e6527f48 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { + cleanupExecutionWorkspaceArtifacts, ensureRuntimeServicesForRun, normalizeAdapterManagedRuntimeServices, realizeExecutionWorkspace, @@ -55,6 +56,10 @@ afterEach(async () => { 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", () => { @@ -211,6 +216,68 @@ describe("realizeExecutionWorkspace", () => { 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", () => { @@ -312,6 +379,84 @@ describe("ensureRuntimeServicesForRun", () => { 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"); + }); }); describe("normalizeAdapterManagedRuntimeServices", () => { @@ -374,6 +519,7 @@ describe("normalizeAdapterManagedRuntimeServices", () => { companyId: "company-1", projectId: "project-1", projectWorkspaceId: "workspace-1", + executionWorkspaceId: null, issueId: "issue-1", serviceName: "preview", provider: "adapter_managed", diff --git a/server/src/app.ts b/server/src/app.ts index 7133a3a3..5a78f02a 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -30,6 +30,13 @@ import type { BetterAuthSessionResult } from "./auth/better-auth.js"; type UiMode = "none" | "static" | "vite-dev"; +export function resolveViteHmrPort(serverPort: number): number { + if (serverPort <= 55_535) { + return serverPort + 10_000; + } + return Math.max(1_024, serverPort - 10_000); +} + export async function createApp( db: Db, opts: { @@ -150,7 +157,7 @@ export async function createApp( if (opts.uiMode === "vite-dev") { const uiRoot = path.resolve(__dirname, "../../ui"); - const hmrPort = opts.serverPort + 10000; + const hmrPort = resolveViteHmrPort(opts.serverPort); const { createServer: createViteServer } = await import("vite"); const vite = await createViteServer({ root: uiRoot, diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index 5c5b6bbe..1661ddc2 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -1,10 +1,19 @@ +import { and, eq } from "drizzle-orm"; import { Router } from "express"; import type { Db } from "@paperclipai/db"; +import { issues, projects, projectWorkspaces } from "@paperclipai/db"; import { updateExecutionWorkspaceSchema } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { executionWorkspaceService, logActivity } from "../services/index.js"; +import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js"; +import { + cleanupExecutionWorkspaceArtifacts, + stopRuntimeServicesForExecutionWorkspace, +} from "../services/workspace-runtime.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; +const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]); + export function executionWorkspaceRoutes(db: Db) { const router = Router(); const svc = executionWorkspaceService(db); @@ -41,10 +50,72 @@ export function executionWorkspaceRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); - const workspace = await svc.update(id, { + const patch: Record = { ...req.body, ...(req.body.cleanupEligibleAt ? { cleanupEligibleAt: new Date(req.body.cleanupEligibleAt) } : {}), - }); + }; + let cleanupWarnings: string[] = []; + + if (req.body.status === "archived" && existing.status !== "archived") { + const linkedIssues = await db + .select({ + id: issues.id, + status: issues.status, + }) + .from(issues) + .where(and(eq(issues.companyId, existing.companyId), eq(issues.executionWorkspaceId, existing.id))); + const activeLinkedIssues = linkedIssues.filter((issue) => !TERMINAL_ISSUE_STATUSES.has(issue.status)); + + if (activeLinkedIssues.length > 0) { + res.status(409).json({ + error: `Cannot archive execution workspace while ${activeLinkedIssues.length} linked issue(s) are still open`, + }); + return; + } + + await stopRuntimeServicesForExecutionWorkspace({ + db, + executionWorkspaceId: existing.id, + workspaceCwd: existing.cwd, + }); + const projectWorkspace = existing.projectWorkspaceId + ? await db + .select({ + cwd: projectWorkspaces.cwd, + cleanupCommand: projectWorkspaces.cleanupCommand, + }) + .from(projectWorkspaces) + .where( + and( + eq(projectWorkspaces.id, existing.projectWorkspaceId), + eq(projectWorkspaces.companyId, existing.companyId), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + const projectPolicy = existing.projectId + ? await db + .select({ + executionWorkspacePolicy: projects.executionWorkspacePolicy, + }) + .from(projects) + .where(and(eq(projects.id, existing.projectId), eq(projects.companyId, existing.companyId))) + .then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)) + : null; + const cleanupResult = await cleanupExecutionWorkspaceArtifacts({ + workspace: existing, + projectWorkspace, + teardownCommand: projectPolicy?.workspaceStrategy?.teardownCommand ?? null, + }); + cleanupWarnings = cleanupResult.warnings; + patch.closedAt = new Date(); + patch.cleanupReason = cleanupWarnings.length > 0 ? cleanupWarnings.join(" | ") : null; + if (!cleanupResult.cleaned) { + patch.status = "cleanup_failed"; + } + } + + const workspace = await svc.update(id, patch); if (!workspace) { res.status(404).json({ error: "Execution workspace not found" }); return; @@ -59,7 +130,10 @@ export function executionWorkspaceRoutes(db: Db) { action: "execution_workspace.updated", entityType: "execution_workspace", entityId: workspace.id, - details: { changedKeys: Object.keys(req.body).sort() }, + details: { + changedKeys: Object.keys(req.body).sort(), + ...(cleanupWarnings.length > 0 ? { cleanupWarnings } : {}), + }, }); res.json(workspace); }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 7ca4949d..0d4ba71b 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -139,6 +139,20 @@ export type ResolvedWorkspaceForRun = { warnings: string[]; }; +type ProjectWorkspaceCandidate = { + id: string; +}; + +export function prioritizeProjectWorkspaceCandidatesForRun( + rows: T[], + preferredWorkspaceId: string | null | undefined, +): T[] { + if (!preferredWorkspaceId) return rows; + const preferredIndex = rows.findIndex((row) => row.id === preferredWorkspaceId); + if (preferredIndex <= 0) return rows; + return [rows[preferredIndex]!, ...rows.slice(0, preferredIndex), ...rows.slice(preferredIndex + 1)]; +} + function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } @@ -537,18 +551,25 @@ export function heartbeatService(db: Db) { ): Promise { const issueId = readNonEmptyString(context.issueId); const contextProjectId = readNonEmptyString(context.projectId); - const issueProjectId = issueId + const contextProjectWorkspaceId = readNonEmptyString(context.projectWorkspaceId); + const issueProjectRef = issueId ? await db - .select({ projectId: issues.projectId }) + .select({ + projectId: issues.projectId, + projectWorkspaceId: issues.projectWorkspaceId, + }) .from(issues) .where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId))) - .then((rows) => rows[0]?.projectId ?? null) + .then((rows) => rows[0] ?? null) : null; + const issueProjectId = issueProjectRef?.projectId ?? null; + const preferredProjectWorkspaceId = + issueProjectRef?.projectWorkspaceId ?? contextProjectWorkspaceId ?? null; const resolvedProjectId = issueProjectId ?? contextProjectId; const useProjectWorkspace = opts?.useProjectWorkspace !== false; const workspaceProjectId = useProjectWorkspace ? resolvedProjectId : null; - const projectWorkspaceRows = workspaceProjectId + const unorderedProjectWorkspaceRows = workspaceProjectId ? await db .select() .from(projectWorkspaces) @@ -560,6 +581,10 @@ export function heartbeatService(db: Db) { ) .orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)) : []; + const projectWorkspaceRows = prioritizeProjectWorkspaceCandidatesForRun( + unorderedProjectWorkspaceRows, + preferredProjectWorkspaceId, + ); const workspaceHints = projectWorkspaceRows.map((workspace) => ({ workspaceId: workspace.id, @@ -569,11 +594,22 @@ export function heartbeatService(db: Db) { })); if (projectWorkspaceRows.length > 0) { + const preferredWorkspace = preferredProjectWorkspaceId + ? projectWorkspaceRows.find((workspace) => workspace.id === preferredProjectWorkspaceId) ?? null + : null; const missingProjectCwds: string[] = []; let hasConfiguredProjectCwd = false; + let preferredWorkspaceWarning: string | null = null; + if (preferredProjectWorkspaceId && !preferredWorkspace) { + preferredWorkspaceWarning = + `Selected project workspace "${preferredProjectWorkspaceId}" is not available on this project.`; + } for (const workspace of projectWorkspaceRows) { const projectCwd = readNonEmptyString(workspace.cwd); if (!projectCwd || projectCwd === REPO_ONLY_CWD_SENTINEL) { + if (preferredWorkspace?.id === workspace.id) { + preferredWorkspaceWarning = `Selected project workspace "${workspace.name}" has no local cwd configured.`; + } continue; } hasConfiguredProjectCwd = true; @@ -590,15 +626,22 @@ export function heartbeatService(db: Db) { repoUrl: workspace.repoUrl, repoRef: workspace.repoRef, workspaceHints, - warnings: [], + warnings: preferredWorkspaceWarning ? [preferredWorkspaceWarning] : [], }; } + if (preferredWorkspace?.id === workspace.id) { + preferredWorkspaceWarning = + `Selected project workspace path "${projectCwd}" is not available yet.`; + } missingProjectCwds.push(projectCwd); } const fallbackCwd = resolveDefaultAgentWorkspaceDir(agent.id); await fs.mkdir(fallbackCwd, { recursive: true }); const warnings: string[] = []; + if (preferredWorkspaceWarning) { + warnings.push(preferredWorkspaceWarning); + } if (missingProjectCwds.length > 0) { const firstMissing = missingProjectCwds[0]; const extraMissingCount = Math.max(0, missingProjectCwds.length - 1); @@ -1464,6 +1507,7 @@ export function heartbeatService(db: Db) { }, issue: issueRef, workspace: executionWorkspace, + executionWorkspaceId: persistedExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null, config: resolvedConfig, adapterEnv, onLog, diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 6f53a164..172d1757 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -46,6 +46,7 @@ export interface RuntimeServiceRef { companyId: string; projectId: string | null; projectWorkspaceId: string | null; + executionWorkspaceId: string | null; issueId: string | null; serviceName: string; status: "starting" | "running" | "stopped" | "failed"; @@ -92,6 +93,17 @@ function stableStringify(value: unknown): string { return JSON.stringify(value); } +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; @@ -126,6 +138,7 @@ function toRuntimeServiceRef(record: RuntimeServiceRecord, overrides?: Partial { + 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; @@ -418,6 +480,98 @@ export async function realizeExecutionWorkspace(input: { }; } +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; +}) { + 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 runWorkspaceCommand({ + command, + cwd: workspacePath ?? input.projectWorkspace?.cwd ?? process.cwd(), + env: cleanupEnv, + label: `Execution workspace cleanup command "${command}"`, + }); + } catch (err) { + warnings.push(err instanceof Error ? err.message : String(err)); + } + } + + if (input.workspace.providerType === "git_worktree" && workspacePath) { + const worktreeExists = await directoryExists(workspacePath); + if (worktreeExists) { + const repoRoot = await resolveGitRepoRootForWorkspaceCleanup( + workspacePath, + input.projectWorkspace?.cwd ?? null, + ); + if (!repoRoot) { + warnings.push(`Could not resolve git repo root for "${workspacePath}".`); + } else { + try { + await runGit(["worktree", "remove", "--force", workspacePath], repoRoot); + } catch (err) { + warnings.push(err instanceof Error ? err.message : String(err)); + } + if (createdByRuntime && input.workspace.branchName) { + try { + await runGit(["branch", "-D", input.workspace.branchName], repoRoot); + } catch (err) { + warnings.push(err instanceof Error ? err.message : String(err)); + } + } + } + } + } 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); + if (projectWorkspaceCwd && resolvedWorkspacePath === projectWorkspaceCwd) { + warnings.push(`Refusing to remove shared project workspace "${workspacePath}".`); + } else { + await fs.rm(resolvedWorkspacePath, { recursive: true, force: true }); + } + } + + 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(); @@ -521,6 +675,7 @@ function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeo companyId: record.companyId, projectId: record.projectId, projectWorkspaceId: record.projectWorkspaceId, + executionWorkspaceId: record.executionWorkspaceId, issueId: record.issueId, scopeType: record.scopeType, scopeId: record.scopeId, @@ -556,6 +711,7 @@ async function persistRuntimeServiceRecord(db: Db | undefined, record: RuntimeSe set: { projectId: values.projectId, projectWorkspaceId: values.projectWorkspaceId, + executionWorkspaceId: values.executionWorkspaceId, issueId: values.issueId, scopeType: values.scopeType, scopeId: values.scopeId, @@ -593,6 +749,7 @@ export function normalizeAdapterManagedRuntimeServices(input: { agent: ExecutionWorkspaceAgentRef; issue: ExecutionWorkspaceIssueRef | null; workspace: RealizedExecutionWorkspace; + executionWorkspaceId?: string | null; reports: AdapterRuntimeServiceReport[]; now?: Date; }): RuntimeServiceRef[] { @@ -629,6 +786,7 @@ export function normalizeAdapterManagedRuntimeServices(input: { 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, @@ -660,6 +818,7 @@ async function startLocalRuntimeService(input: { agent: ExecutionWorkspaceAgentRef; issue: ExecutionWorkspaceIssueRef | null; workspace: RealizedExecutionWorkspace; + executionWorkspaceId?: string | null; adapterEnv: Record; service: Record; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; @@ -683,7 +842,10 @@ async function startLocalRuntimeService(input: { port, }); const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd); - const env: Record = { ...process.env, ...input.adapterEnv } as Record; + 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); @@ -735,6 +897,7 @@ async function startLocalRuntimeService(input: { companyId: input.agent.companyId, projectId: input.workspace.projectId, projectWorkspaceId: input.workspace.workspaceId, + executionWorkspaceId: input.executionWorkspaceId ?? null, issueId: input.issue?.id ?? null, serviceName, status: "running", @@ -791,6 +954,28 @@ async function stopRuntimeService(serviceId: string) { 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); @@ -820,6 +1005,7 @@ export async function ensureRuntimeServicesForRun(input: { agent: ExecutionWorkspaceAgentRef; issue: ExecutionWorkspaceIssueRef | null; workspace: RealizedExecutionWorkspace; + executionWorkspaceId?: string | null; config: Record; adapterEnv: Record; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; @@ -871,6 +1057,7 @@ export async function ensureRuntimeServicesForRun(input: { agent: input.agent, issue: input.issue, workspace: input.workspace, + executionWorkspaceId: input.executionWorkspaceId, adapterEnv: input.adapterEnv, service, onLog: input.onLog, @@ -911,6 +1098,32 @@ export async function releaseRuntimeServicesForRun(runId: string) { } } +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; + return path.resolve(record.cwd).startsWith(normalizedWorkspaceCwd); + }) + .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, @@ -978,6 +1191,7 @@ export async function persistAdapterManagedRuntimeServices(input: { agent: ExecutionWorkspaceAgentRef; issue: ExecutionWorkspaceIssueRef | null; workspace: RealizedExecutionWorkspace; + executionWorkspaceId?: string | null; reports: AdapterRuntimeServiceReport[]; }) { const refs = normalizeAdapterManagedRuntimeServices(input); @@ -1000,6 +1214,7 @@ export async function persistAdapterManagedRuntimeServices(input: { companyId: ref.companyId, projectId: ref.projectId, projectWorkspaceId: ref.projectWorkspaceId, + executionWorkspaceId: ref.executionWorkspaceId, issueId: ref.issueId, scopeType: ref.scopeType, scopeId: ref.scopeId, @@ -1028,6 +1243,7 @@ export async function persistAdapterManagedRuntimeServices(input: { set: { projectId: ref.projectId, projectWorkspaceId: ref.projectWorkspaceId, + executionWorkspaceId: ref.executionWorkspaceId, issueId: ref.issueId, scopeType: ref.scopeType, scopeId: ref.scopeId,