import { and, eq } from "drizzle-orm"; import { Router, type Request, type Response } from "express"; import type { Db } from "@paperclipai/db"; import { issues, projects, projectWorkspaces } from "@paperclipai/db"; import { findWorkspaceCommandDefinition, matchWorkspaceRuntimeServiceToCommand, updateExecutionWorkspaceSchema, workspaceRuntimeControlTargetSchema, } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js"; import { mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig } from "../services/execution-workspaces.js"; import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js"; import { readProjectWorkspaceRuntimeConfig } from "../services/project-workspace-runtime-config.js"; import { buildWorkspaceRuntimeDesiredStatePatch, cleanupExecutionWorkspaceArtifacts, ensurePersistedExecutionWorkspaceAvailable, listConfiguredRuntimeServiceEntries, runWorkspaceJobForControl, startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForExecutionWorkspace, } from "../services/workspace-runtime.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { assertNoAgentHostWorkspaceCommandMutation, collectExecutionWorkspaceCommandPaths, } from "./workspace-command-authz.js"; import { assertCanManageExecutionWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js"; import { appendWithCap } from "../adapters/utils.js"; const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024; export function executionWorkspaceRoutes(db: Db) { const router = Router(); const svc = executionWorkspaceService(db); const workspaceOperationsSvc = workspaceOperationService(db); router.get("/companies/:companyId/execution-workspaces", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const filters = { projectId: req.query.projectId as string | undefined, projectWorkspaceId: req.query.projectWorkspaceId as string | undefined, issueId: req.query.issueId as string | undefined, status: req.query.status as string | undefined, reuseEligible: req.query.reuseEligible === "true", }; const workspaces = req.query.summary === "true" ? await svc.listSummaries(companyId, filters) : await svc.list(companyId, filters); res.json(workspaces); }); router.get("/execution-workspaces/:id", async (req, res) => { const id = req.params.id as string; const workspace = await svc.getById(id); if (!workspace) { res.status(404).json({ error: "Execution workspace not found" }); return; } assertCompanyAccess(req, workspace.companyId); res.json(workspace); }); router.get("/execution-workspaces/:id/close-readiness", async (req, res) => { const id = req.params.id as string; const workspace = await svc.getById(id); if (!workspace) { res.status(404).json({ error: "Execution workspace not found" }); return; } assertCompanyAccess(req, workspace.companyId); const readiness = await svc.getCloseReadiness(id); if (!readiness) { res.status(404).json({ error: "Execution workspace not found" }); return; } res.json(readiness); }); router.get("/execution-workspaces/:id/workspace-operations", async (req, res) => { const id = req.params.id as string; const workspace = await svc.getById(id); if (!workspace) { res.status(404).json({ error: "Execution workspace not found" }); return; } assertCompanyAccess(req, workspace.companyId); const operations = await workspaceOperationsSvc.listForExecutionWorkspace(id); res.json(operations); }); async function handleExecutionWorkspaceRuntimeCommand(req: Request, res: Response) { const id = req.params.id as string; const action = String(req.params.action ?? "").trim().toLowerCase(); if (action !== "start" && action !== "stop" && action !== "restart" && action !== "run") { res.status(404).json({ error: "Workspace command action not found" }); return; } const existing = await svc.getById(id); if (!existing) { res.status(404).json({ error: "Execution workspace not found" }); return; } assertCompanyAccess(req, existing.companyId); await assertCanManageExecutionWorkspaceRuntimeServices(db, req, { companyId: existing.companyId, executionWorkspaceId: existing.id, sourceIssueId: existing.sourceIssueId, }); const workspaceCwd = existing.cwd; if (!workspaceCwd) { res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can run workspace commands" }); return; } const projectWorkspace = existing.projectWorkspaceId ? await db .select({ id: projectWorkspaces.id, cwd: projectWorkspaces.cwd, repoUrl: projectWorkspaces.repoUrl, repoRef: projectWorkspaces.repoRef, defaultRef: projectWorkspaces.defaultRef, metadata: projectWorkspaces.metadata, }) .from(projectWorkspaces) .where( and( eq(projectWorkspaces.id, existing.projectWorkspaceId), eq(projectWorkspaces.companyId, existing.companyId), ), ) .then((rows) => rows[0] ?? null) : null; const projectWorkspaceRuntime = readProjectWorkspaceRuntimeConfig( (projectWorkspace?.metadata as Record | null) ?? null, )?.workspaceRuntime ?? 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 effectiveRuntimeConfig = existing.config?.workspaceRuntime ?? projectWorkspaceRuntime ?? null; const target = req.body as { workspaceCommandId?: string | null; runtimeServiceId?: string | null; serviceIndex?: number | null }; const configuredServices = effectiveRuntimeConfig ? listConfiguredRuntimeServiceEntries({ workspaceRuntime: effectiveRuntimeConfig }) : []; const workspaceCommand = effectiveRuntimeConfig ? findWorkspaceCommandDefinition(effectiveRuntimeConfig, target.workspaceCommandId ?? null) : null; if (target.workspaceCommandId && !workspaceCommand) { res.status(404).json({ error: "Workspace command not found for this execution workspace" }); return; } if (target.runtimeServiceId && !(existing.runtimeServices ?? []).some((service) => service.id === target.runtimeServiceId)) { res.status(404).json({ error: "Runtime service not found for this execution workspace" }); return; } const matchedRuntimeService = workspaceCommand?.kind === "service" && !target.runtimeServiceId ? matchWorkspaceRuntimeServiceToCommand(workspaceCommand, existing.runtimeServices ?? []) : null; const selectedRuntimeServiceId = target.runtimeServiceId ?? matchedRuntimeService?.id ?? null; const selectedServiceIndex = workspaceCommand?.kind === "service" ? workspaceCommand.serviceIndex : target.serviceIndex ?? null; if ( selectedServiceIndex !== undefined && selectedServiceIndex !== null && (selectedServiceIndex < 0 || selectedServiceIndex >= configuredServices.length) ) { res.status(422).json({ error: "Selected runtime service is not defined in this execution workspace runtime config" }); return; } if (workspaceCommand?.kind === "job" && action !== "run") { res.status(422).json({ error: `Workspace job "${workspaceCommand.name}" can only be run` }); return; } if (workspaceCommand?.kind === "service" && action === "run") { res.status(422).json({ error: `Workspace service "${workspaceCommand.name}" should be started or restarted, not run` }); return; } if (action === "run" && !workspaceCommand) { res.status(422).json({ error: "Select a workspace job to run" }); return; } if ((action === "start" || action === "restart") && !effectiveRuntimeConfig) { res.status(422).json({ error: "Execution workspace has no workspace command configuration or inherited project workspace default" }); return; } const actor = getActorInfo(req); const recorder = workspaceOperationsSvc.createRecorder({ companyId: existing.companyId, executionWorkspaceId: existing.id, }); let runtimeServiceCount = existing.runtimeServices?.length ?? 0; let stdout = ""; let stderr = ""; const operation = await recorder.recordOperation({ phase: action === "stop" ? "workspace_teardown" : "workspace_provision", command: workspaceCommand?.command ?? `workspace command ${action}`, cwd: existing.cwd, metadata: { action, executionWorkspaceId: existing.id, workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, workspaceCommandKind: workspaceCommand?.kind ?? null, workspaceCommandName: workspaceCommand?.name ?? null, runtimeServiceId: selectedRuntimeServiceId, serviceIndex: selectedServiceIndex, }, run: async () => { const ensureWorkspaceAvailable = async () => await ensurePersistedExecutionWorkspaceAvailable({ base: { baseCwd: projectWorkspace?.cwd ?? workspaceCwd, source: existing.mode === "shared_workspace" ? "project_primary" : "task_session", projectId: existing.projectId, workspaceId: existing.projectWorkspaceId, repoUrl: existing.repoUrl, repoRef: existing.baseRef, }, workspace: { mode: existing.mode, strategyType: existing.strategyType, cwd: existing.cwd, providerRef: existing.providerRef, projectId: existing.projectId, projectWorkspaceId: existing.projectWorkspaceId, repoUrl: existing.repoUrl, baseRef: existing.baseRef, branchName: existing.branchName, config: { ...existing.config, provisionCommand: existing.config?.provisionCommand ?? projectPolicy?.workspaceStrategy?.provisionCommand ?? null, }, }, issue: existing.sourceIssueId ? { id: existing.sourceIssueId, identifier: null, title: existing.name, } : null, agent: { id: actor.agentId ?? null, name: actor.actorType === "user" ? "Board" : "Agent", companyId: existing.companyId, }, recorder, }); if (action === "run") { if (!workspaceCommand || workspaceCommand.kind !== "job") { throw new Error("Workspace job selection is required"); } const availableWorkspace = await ensureWorkspaceAvailable(); if (!availableWorkspace) { throw new Error("Execution workspace needs a local path before Paperclip can run workspace commands"); } return await runWorkspaceJobForControl({ actor: { id: actor.agentId ?? null, name: actor.actorType === "user" ? "Board" : "Agent", companyId: existing.companyId, }, issue: existing.sourceIssueId ? { id: existing.sourceIssueId, identifier: null, title: existing.name, } : null, workspace: availableWorkspace, command: workspaceCommand.rawConfig, adapterEnv: {}, recorder, metadata: { action, executionWorkspaceId: existing.id, workspaceCommandId: workspaceCommand.id, }, }).then((nestedOperation) => ({ status: "succeeded" as const, exitCode: 0, metadata: { nestedOperationId: nestedOperation?.id ?? null, runtimeServiceCount, }, })); } const onLog = async (stream: "stdout" | "stderr", chunk: string) => { if (stream === "stdout") stdout = appendWithCap(stdout, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS); else stderr = appendWithCap(stderr, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS); }; if (action === "stop" || action === "restart") { await stopRuntimeServicesForExecutionWorkspace({ db, executionWorkspaceId: existing.id, workspaceCwd, runtimeServiceId: selectedRuntimeServiceId, }); } if (action === "start" || action === "restart") { const availableWorkspace = await ensureWorkspaceAvailable(); if (!availableWorkspace) { throw new Error("Execution workspace needs a local path before Paperclip can manage local runtime services"); } const startedServices = await startRuntimeServicesForWorkspaceControl({ db, actor: { id: actor.agentId ?? null, name: actor.actorType === "user" ? "Board" : "Agent", companyId: existing.companyId, }, issue: existing.sourceIssueId ? { id: existing.sourceIssueId, identifier: null, title: existing.name, } : null, workspace: availableWorkspace, executionWorkspaceId: existing.id, config: { workspaceRuntime: effectiveRuntimeConfig }, adapterEnv: {}, onLog, serviceIndex: selectedServiceIndex, }); runtimeServiceCount = startedServices.length; } else { runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (existing.runtimeServices?.length ?? 1) - 1) : 0; } const currentDesiredState: "running" | "stopped" = existing.config?.desiredState ?? ((existing.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running") ? "running" : "stopped"); const nextRuntimeState: { desiredState: "running" | "stopped"; serviceStates: Record | null | undefined; } = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null) ? { desiredState: currentDesiredState, serviceStates: existing.config?.serviceStates ?? null, } : buildWorkspaceRuntimeDesiredStatePatch({ config: { workspaceRuntime: effectiveRuntimeConfig }, currentDesiredState, currentServiceStates: existing.config?.serviceStates ?? null, action, serviceIndex: selectedServiceIndex, }); const metadata = mergeExecutionWorkspaceConfig(existing.metadata as Record | null, { desiredState: nextRuntimeState.desiredState, serviceStates: nextRuntimeState.serviceStates, }); await svc.update(existing.id, { metadata }); return { status: "succeeded", stdout, stderr, system: action === "stop" ? "Stopped execution workspace runtime services.\n" : action === "restart" ? "Restarted execution workspace runtime services.\n" : "Started execution workspace runtime services.\n", metadata: { runtimeServiceCount, workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, runtimeServiceId: selectedRuntimeServiceId, serviceIndex: selectedServiceIndex, }, }; }, }); const workspace = await svc.getById(id); if (!workspace) { res.status(404).json({ error: "Execution workspace not found" }); return; } await logActivity(db, { companyId: existing.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: `execution_workspace.runtime_${action}`, entityType: "execution_workspace", entityId: existing.id, details: { runtimeServiceCount, workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, workspaceCommandKind: workspaceCommand?.kind ?? null, workspaceCommandName: workspaceCommand?.name ?? null, runtimeServiceId: selectedRuntimeServiceId, serviceIndex: selectedServiceIndex, }, }); res.json({ workspace, operation, }); } router.post("/execution-workspaces/:id/runtime-services/:action", validate(workspaceRuntimeControlTargetSchema), handleExecutionWorkspaceRuntimeCommand); router.post("/execution-workspaces/:id/runtime-commands/:action", validate(workspaceRuntimeControlTargetSchema), handleExecutionWorkspaceRuntimeCommand); router.patch("/execution-workspaces/:id", validate(updateExecutionWorkspaceSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); if (!existing) { res.status(404).json({ error: "Execution workspace not found" }); return; } assertCompanyAccess(req, existing.companyId); assertNoAgentHostWorkspaceCommandMutation( req, collectExecutionWorkspaceCommandPaths({ config: req.body.config, metadata: req.body.metadata, }), ); const patch: Record = { ...(req.body.name === undefined ? {} : { name: req.body.name }), ...(req.body.cwd === undefined ? {} : { cwd: req.body.cwd }), ...(req.body.repoUrl === undefined ? {} : { repoUrl: req.body.repoUrl }), ...(req.body.baseRef === undefined ? {} : { baseRef: req.body.baseRef }), ...(req.body.branchName === undefined ? {} : { branchName: req.body.branchName }), ...(req.body.providerRef === undefined ? {} : { providerRef: req.body.providerRef }), ...(req.body.status === undefined ? {} : { status: req.body.status }), ...(req.body.cleanupReason === undefined ? {} : { cleanupReason: req.body.cleanupReason }), ...(req.body.cleanupEligibleAt !== undefined ? { cleanupEligibleAt: req.body.cleanupEligibleAt ? new Date(req.body.cleanupEligibleAt) : null } : {}), }; if (req.body.metadata !== undefined || req.body.config !== undefined) { const requestedMetadata = req.body.metadata === undefined ? (existing.metadata as Record | null) : (req.body.metadata as Record | null); patch.metadata = req.body.config === undefined ? requestedMetadata : mergeExecutionWorkspaceConfig(requestedMetadata, req.body.config ?? null); } let workspace = existing; let cleanupWarnings: string[] = []; const configForCleanup = readExecutionWorkspaceConfig( ((patch.metadata as Record | null | undefined) ?? (existing.metadata as Record | null)) ?? null, ); if (req.body.status === "archived" && existing.status !== "archived") { const readiness = await svc.getCloseReadiness(existing.id); if (!readiness) { res.status(404).json({ error: "Execution workspace not found" }); return; } if (readiness.state === "blocked") { res.status(409).json({ error: readiness.blockingReasons[0] ?? "Execution workspace cannot be closed right now", closeReadiness: readiness, }); return; } const closedAt = new Date(); const archivedWorkspace = await svc.update(id, { ...patch, status: "archived", closedAt, cleanupReason: null, }); if (!archivedWorkspace) { res.status(404).json({ error: "Execution workspace not found" }); return; } workspace = archivedWorkspace; if (existing.mode === "shared_workspace") { await db .update(issues) .set({ executionWorkspaceId: null, updatedAt: new Date(), }) .where( and( eq(issues.companyId, existing.companyId), eq(issues.executionWorkspaceId, existing.id), ), ); } try { 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: configForCleanup?.teardownCommand ?? projectPolicy?.workspaceStrategy?.teardownCommand ?? null, cleanupCommand: configForCleanup?.cleanupCommand ?? null, recorder: workspaceOperationsSvc.createRecorder({ companyId: existing.companyId, executionWorkspaceId: existing.id, }), }); cleanupWarnings = cleanupResult.warnings; const cleanupPatch: Record = { closedAt, cleanupReason: cleanupWarnings.length > 0 ? cleanupWarnings.join(" | ") : null, }; if (!cleanupResult.cleaned) { cleanupPatch.status = "cleanup_failed"; } if (cleanupResult.warnings.length > 0 || !cleanupResult.cleaned) { workspace = (await svc.update(id, cleanupPatch)) ?? workspace; } } catch (error) { const failureReason = error instanceof Error ? error.message : String(error); workspace = (await svc.update(id, { status: "cleanup_failed", closedAt, cleanupReason: failureReason, })) ?? workspace; res.status(500).json({ error: `Failed to archive execution workspace: ${failureReason}`, }); return; } } else { const updatedWorkspace = await svc.update(id, patch); if (!updatedWorkspace) { res.status(404).json({ error: "Execution workspace not found" }); return; } workspace = updatedWorkspace; } const actor = getActorInfo(req); await logActivity(db, { companyId: existing.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "execution_workspace.updated", entityType: "execution_workspace", entityId: workspace.id, details: { changedKeys: Object.keys(req.body).sort(), ...(cleanupWarnings.length > 0 ? { cleanupWarnings } : {}), }, }); res.json(workspace); }); return router; }