From 1f1fe9c9895eb9cb2c658d944c0bbc8110e1e9b0 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 16:46:43 -0500 Subject: [PATCH] Add workspace runtime controls Expose project and execution workspace runtime defaults, control endpoints, startup recovery, and operator UI for start/stop/restart flows. Co-Authored-By: Paperclip --- packages/shared/src/index.ts | 2 + packages/shared/src/types/index.ts | 2 + packages/shared/src/types/project.ts | 7 +- .../shared/src/types/workspace-runtime.ts | 8 + .../src/validators/execution-workspace.ts | 1 + packages/shared/src/validators/index.ts | 1 + packages/shared/src/validators/project.ts | 6 + .../execution-workspaces-service.test.ts | 2 + server/src/index.ts | 11 +- server/src/routes/execution-workspaces.ts | 182 ++++++++++++++ server/src/routes/projects.ts | 143 ++++++++++- server/src/services/execution-workspaces.ts | 9 + server/src/services/heartbeat.ts | 31 ++- server/src/services/index.ts | 2 +- .../project-workspace-runtime-config.ts | 59 +++++ server/src/services/projects.ts | 24 +- server/src/services/workspace-runtime.ts | 229 +++++++++++++++++- ui/src/api/execution-workspaces.ts | 9 +- ui/src/api/projects.ts | 12 +- ui/src/lib/project-workspaces-tab.test.ts | 10 +- ui/src/lib/project-workspaces-tab.ts | 42 ++++ ui/src/lib/queryKeys.ts | 1 + ui/src/pages/ExecutionWorkspaceDetail.tsx | 190 ++++++++++++++- ui/src/pages/ProjectDetail.tsx | 73 +++++- ui/src/pages/ProjectWorkspaceDetail.tsx | 128 +++++++++- 25 files changed, 1133 insertions(+), 51 deletions(-) create mode 100644 server/src/services/project-workspace-runtime-config.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0c5e08b8..89d24f21 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -193,10 +193,12 @@ export type { ExecutionWorkspaceCloseLinkedIssue, ExecutionWorkspaceCloseReadiness, ExecutionWorkspaceCloseReadinessState, + ProjectWorkspaceRuntimeConfig, WorkspaceRuntimeService, WorkspaceOperation, WorkspaceOperationPhase, WorkspaceOperationStatus, + WorkspaceRuntimeDesiredState, ExecutionWorkspaceStrategyType, ExecutionWorkspaceMode, ExecutionWorkspaceProviderType, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index eaa61faf..dfe4b9d5 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -57,7 +57,9 @@ export type { ExecutionWorkspaceCloseLinkedIssue, ExecutionWorkspaceCloseReadiness, ExecutionWorkspaceCloseReadinessState, + ProjectWorkspaceRuntimeConfig, WorkspaceRuntimeService, + WorkspaceRuntimeDesiredState, ExecutionWorkspaceStrategyType, ExecutionWorkspaceMode, ExecutionWorkspaceProviderType, diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index ad977a63..d843b425 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -1,5 +1,9 @@ import type { PauseReason, ProjectStatus } from "../constants.js"; -import type { ProjectExecutionWorkspacePolicy, WorkspaceRuntimeService } from "./workspace-runtime.js"; +import type { + ProjectExecutionWorkspacePolicy, + ProjectWorkspaceRuntimeConfig, + WorkspaceRuntimeService, +} from "./workspace-runtime.js"; export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path"; export type ProjectWorkspaceVisibility = "default" | "advanced"; @@ -26,6 +30,7 @@ export interface ProjectWorkspace { remoteWorkspaceRef: string | null; sharedWorkspaceKey: string | null; metadata: Record | null; + runtimeConfig: ProjectWorkspaceRuntimeConfig | null; isPrimary: boolean; runtimeServices?: WorkspaceRuntimeService[]; createdAt: Date; diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 6facf57e..2b2c4e2d 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -45,6 +45,8 @@ export type ExecutionWorkspaceCloseActionKind = | "git_branch_delete" | "remove_local_directory"; +export type WorkspaceRuntimeDesiredState = "running" | "stopped"; + export interface ExecutionWorkspaceStrategy { type: ExecutionWorkspaceStrategyType; baseRef?: string | null; @@ -59,6 +61,12 @@ export interface ExecutionWorkspaceConfig { teardownCommand: string | null; cleanupCommand: string | null; workspaceRuntime: Record | null; + desiredState: WorkspaceRuntimeDesiredState | null; +} + +export interface ProjectWorkspaceRuntimeConfig { + workspaceRuntime: Record | null; + desiredState: WorkspaceRuntimeDesiredState | null; } export interface ExecutionWorkspaceCloseAction { diff --git a/packages/shared/src/validators/execution-workspace.ts b/packages/shared/src/validators/execution-workspace.ts index 6a31ba3b..d761c528 100644 --- a/packages/shared/src/validators/execution-workspace.ts +++ b/packages/shared/src/validators/execution-workspace.ts @@ -13,6 +13,7 @@ export const executionWorkspaceConfigSchema = z.object({ teardownCommand: z.string().optional().nullable(), cleanupCommand: z.string().optional().nullable(), workspaceRuntime: z.record(z.unknown()).optional().nullable(), + desiredState: z.enum(["running", "stopped"]).optional().nullable(), }).strict(); export const executionWorkspaceCloseReadinessStateSchema = z.enum([ diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 34455deb..9b94438d 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -109,6 +109,7 @@ export { createProjectWorkspaceSchema, updateProjectWorkspaceSchema, projectExecutionWorkspacePolicySchema, + projectWorkspaceRuntimeConfigSchema, type CreateProject, type UpdateProject, type CreateProjectWorkspace, diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index cf5aba8a..89308ff4 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -27,6 +27,11 @@ export const projectExecutionWorkspacePolicySchema = z }) .strict(); +export const projectWorkspaceRuntimeConfigSchema = z.object({ + workspaceRuntime: z.record(z.unknown()).optional().nullable(), + desiredState: z.enum(["running", "stopped"]).optional().nullable(), +}).strict(); + const projectWorkspaceSourceTypeSchema = z.enum(["local_path", "git_repo", "remote_managed", "non_git_path"]); const projectWorkspaceVisibilitySchema = z.enum(["default", "advanced"]); @@ -44,6 +49,7 @@ const projectWorkspaceFields = { remoteWorkspaceRef: z.string().optional().nullable(), sharedWorkspaceKey: z.string().optional().nullable(), metadata: z.record(z.unknown()).optional().nullable(), + runtimeConfig: projectWorkspaceRuntimeConfigSchema.optional().nullable(), }; function validateProjectWorkspace(value: Record, ctx: z.RefinementCtx) { diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index 57e86a42..a6f18892 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -41,6 +41,7 @@ describe("execution workspace config helpers", () => { provisionCommand: "bash ./scripts/provision-worktree.sh", teardownCommand: "bash ./scripts/teardown-worktree.sh", cleanupCommand: "pkill -f vite || true", + desiredState: null, workspaceRuntime: { services: [{ name: "web", command: "pnpm dev", port: 3100 }], }, @@ -70,6 +71,7 @@ describe("execution workspace config helpers", () => { provisionCommand: "bash ./scripts/provision-worktree.sh", teardownCommand: "bash ./scripts/teardown-worktree.sh", cleanupCommand: "pkill -f vite || true", + desiredState: null, workspaceRuntime: { services: [{ name: "web", command: "pnpm dev" }], }, diff --git a/server/src/index.ts b/server/src/index.ts index 7ebfa7d1..cfcde31a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -28,7 +28,7 @@ import { createApp } from "./app.js"; import { loadConfig } from "./config.js"; import { logger } from "./middleware/logger.js"; import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; -import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup, routineService } from "./services/index.js"; +import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup, restartDesiredRuntimeServicesOnStartup, routineService } from "./services/index.js"; import { createStorageServiceFromConfig } from "./storage/index.js"; import { printStartupBanner } from "./startup-banner.js"; import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js"; @@ -557,6 +557,15 @@ export async function startServer(): Promise { "reconciled persisted runtime services from a previous server process", ); } + return restartDesiredRuntimeServicesOnStartup(db as any); + }) + .then((result) => { + if (result && result.restarted > 0) { + logger.warn( + { restarted: result.restarted, failed: result.failed }, + "restarted desired workspace runtime services on startup", + ); + } }) .catch((err) => { logger.error({ err }, "startup reconciliation of persisted runtime services failed"); diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index 697f3413..d4291967 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -7,8 +7,10 @@ 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 { cleanupExecutionWorkspaceArtifacts, + startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForExecutionWorkspace, } from "../services/workspace-runtime.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; @@ -58,6 +60,186 @@ export function executionWorkspaceRoutes(db: Db) { 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); + }); + + router.post("/execution-workspaces/:id/runtime-services/:action", async (req, res) => { + const id = req.params.id as string; + const action = String(req.params.action ?? "").trim().toLowerCase(); + if (action !== "start" && action !== "stop" && action !== "restart") { + res.status(404).json({ error: "Runtime service 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); + + const workspaceCwd = existing.cwd; + if (!workspaceCwd) { + res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can manage local runtime services" }); + 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 effectiveRuntimeConfig = existing.config?.workspaceRuntime ?? projectWorkspaceRuntime ?? null; + + if ((action === "start" || action === "restart") && !effectiveRuntimeConfig) { + res.status(422).json({ error: "Execution workspace has no runtime service 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; + const stdout: string[] = []; + const stderr: string[] = []; + + const operation = await recorder.recordOperation({ + phase: action === "stop" ? "workspace_teardown" : "workspace_provision", + command: `workspace runtime ${action}`, + cwd: existing.cwd, + metadata: { + action, + executionWorkspaceId: existing.id, + }, + run: async () => { + const onLog = async (stream: "stdout" | "stderr", chunk: string) => { + if (stream === "stdout") stdout.push(chunk); + else stderr.push(chunk); + }; + + if (action === "stop" || action === "restart") { + await stopRuntimeServicesForExecutionWorkspace({ + db, + executionWorkspaceId: existing.id, + workspaceCwd, + }); + } + + if (action === "start" || action === "restart") { + 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: { + baseCwd: workspaceCwd, + source: existing.mode === "shared_workspace" ? "project_primary" : "task_session", + projectId: existing.projectId, + workspaceId: existing.projectWorkspaceId, + repoUrl: existing.repoUrl, + repoRef: existing.baseRef, + strategy: existing.strategyType === "git_worktree" ? "git_worktree" : "project_primary", + cwd: workspaceCwd, + branchName: existing.branchName, + worktreePath: existing.strategyType === "git_worktree" ? workspaceCwd : null, + warnings: [], + created: false, + }, + executionWorkspaceId: existing.id, + config: { workspaceRuntime: effectiveRuntimeConfig }, + adapterEnv: {}, + onLog, + }); + runtimeServiceCount = startedServices.length; + } else { + runtimeServiceCount = 0; + } + + const metadata = mergeExecutionWorkspaceConfig(existing.metadata as Record | null, { + desiredState: action === "stop" ? "stopped" : "running", + }); + await svc.update(existing.id, { metadata }); + + return { + status: "succeeded", + stdout: stdout.join(""), + stderr: stderr.join(""), + 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, + }, + }; + }, + }); + + 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, + }, + }); + + res.json({ + workspace, + operation, + }); + }); + router.patch("/execution-workspaces/:id", validate(updateExecutionWorkspaceSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 51555ff5..b200b354 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -8,13 +8,15 @@ import { updateProjectWorkspaceSchema, } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; -import { projectService, logActivity } from "../services/index.js"; +import { projectService, logActivity, workspaceOperationService } from "../services/index.js"; import { conflict } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js"; export function projectRoutes(db: Db) { const router = Router(); const svc = projectService(db); + const workspaceOperations = workspaceOperationService(db); async function resolveCompanyIdForProjectReference(req: Request) { const companyIdQuery = req.query.companyId; @@ -229,6 +231,145 @@ export function projectRoutes(db: Db) { }, ); + router.post("/projects/:id/workspaces/:workspaceId/runtime-services/:action", async (req, res) => { + const id = req.params.id as string; + const workspaceId = req.params.workspaceId as string; + const action = String(req.params.action ?? "").trim().toLowerCase(); + if (action !== "start" && action !== "stop" && action !== "restart") { + res.status(404).json({ error: "Runtime service action not found" }); + return; + } + + const project = await svc.getById(id); + if (!project) { + res.status(404).json({ error: "Project not found" }); + return; + } + assertCompanyAccess(req, project.companyId); + + const workspace = project.workspaces.find((entry) => entry.id === workspaceId) ?? null; + if (!workspace) { + res.status(404).json({ error: "Project workspace not found" }); + return; + } + + const workspaceCwd = workspace.cwd; + if (!workspaceCwd) { + res.status(422).json({ error: "Project workspace needs a local path before Paperclip can manage local runtime services" }); + return; + } + + const runtimeConfig = workspace.runtimeConfig?.workspaceRuntime ?? null; + if ((action === "start" || action === "restart") && !runtimeConfig) { + res.status(422).json({ error: "Project workspace has no runtime service configuration" }); + return; + } + + const actor = getActorInfo(req); + const recorder = workspaceOperations.createRecorder({ companyId: project.companyId }); + let runtimeServiceCount = workspace.runtimeServices?.length ?? 0; + const stdout: string[] = []; + const stderr: string[] = []; + + const operation = await recorder.recordOperation({ + phase: action === "stop" ? "workspace_teardown" : "workspace_provision", + command: `workspace runtime ${action}`, + cwd: workspace.cwd, + metadata: { + action, + projectId: project.id, + projectWorkspaceId: workspace.id, + }, + run: async () => { + const onLog = async (stream: "stdout" | "stderr", chunk: string) => { + if (stream === "stdout") stdout.push(chunk); + else stderr.push(chunk); + }; + + if (action === "stop" || action === "restart") { + await stopRuntimeServicesForProjectWorkspace({ + db, + projectWorkspaceId: workspace.id, + }); + } + + if (action === "start" || action === "restart") { + const startedServices = await startRuntimeServicesForWorkspaceControl({ + db, + actor: { + id: actor.agentId ?? null, + name: actor.actorType === "user" ? "Board" : "Agent", + companyId: project.companyId, + }, + issue: null, + workspace: { + baseCwd: workspaceCwd, + source: "project_primary", + projectId: project.id, + workspaceId: workspace.id, + repoUrl: workspace.repoUrl, + repoRef: workspace.repoRef, + strategy: "project_primary", + cwd: workspaceCwd, + branchName: workspace.defaultRef ?? workspace.repoRef ?? null, + worktreePath: null, + warnings: [], + created: false, + }, + config: { workspaceRuntime: runtimeConfig }, + adapterEnv: {}, + onLog, + }); + runtimeServiceCount = startedServices.length; + } else { + runtimeServiceCount = 0; + } + + await svc.updateWorkspace(project.id, workspace.id, { + runtimeConfig: { + desiredState: action === "stop" ? "stopped" : "running", + }, + }); + + return { + status: "succeeded", + stdout: stdout.join(""), + stderr: stderr.join(""), + system: + action === "stop" + ? "Stopped project workspace runtime services.\n" + : action === "restart" + ? "Restarted project workspace runtime services.\n" + : "Started project workspace runtime services.\n", + metadata: { + runtimeServiceCount, + }, + }; + }, + }); + + const updatedWorkspace = (await svc.listWorkspaces(project.id)).find((entry) => entry.id === workspace.id) ?? workspace; + + await logActivity(db, { + companyId: project.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + action: `project.workspace_runtime_${action}`, + entityType: "project", + entityId: project.id, + details: { + projectWorkspaceId: workspace.id, + runtimeServiceCount, + }, + }); + + res.json({ + workspace: updatedWorkspace, + operation, + }); + }); + router.delete("/projects/:id/workspaces/:workspaceId", async (req, res) => { const id = req.params.id as string; const workspaceId = req.params.workspaceId as string; diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index ffa94f9d..758a7e09 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -187,6 +187,7 @@ export function readExecutionWorkspaceConfig(metadata: Record | teardownCommand: readNullableString(raw.teardownCommand), cleanupCommand: readNullableString(raw.cleanupCommand), workspaceRuntime: cloneRecord(raw.workspaceRuntime), + desiredState: raw.desiredState === "running" || raw.desiredState === "stopped" ? raw.desiredState : null, }; const hasConfig = Object.values(config).some((value) => { @@ -208,6 +209,7 @@ export function mergeExecutionWorkspaceConfig( teardownCommand: null, cleanupCommand: null, workspaceRuntime: null, + desiredState: null, }; if (patch === null) { @@ -220,6 +222,12 @@ export function mergeExecutionWorkspaceConfig( teardownCommand: patch.teardownCommand !== undefined ? readNullableString(patch.teardownCommand) : current.teardownCommand, cleanupCommand: patch.cleanupCommand !== undefined ? readNullableString(patch.cleanupCommand) : current.cleanupCommand, workspaceRuntime: patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime, + desiredState: + patch.desiredState !== undefined + ? patch.desiredState === "running" || patch.desiredState === "stopped" + ? patch.desiredState + : null + : current.desiredState, }; const hasConfig = Object.values(nextConfig).some((value) => { @@ -234,6 +242,7 @@ export function mergeExecutionWorkspaceConfig( teardownCommand: nextConfig.teardownCommand, cleanupCommand: nextConfig.cleanupCommand, workspaceRuntime: nextConfig.workspaceRuntime, + desiredState: nextConfig.desiredState, }; } else { delete nextMetadata.config; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 3c94f76d..e4c2b35b 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -51,6 +51,7 @@ import { resolveExecutionWorkspaceMode, } from "./execution-workspace-policy.js"; import { instanceSettingsService } from "./instance-settings.js"; +import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js"; import { hasSessionCompactionThresholds, @@ -79,21 +80,22 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([ function applyPersistedExecutionWorkspaceConfig(input: { config: Record; workspaceConfig: ExecutionWorkspaceConfig | null; + projectWorkspaceRuntime: Record | null; mode: ReturnType; }) { - if (!input.workspaceConfig) return input.config; - const nextConfig = { ...input.config }; if (input.mode !== "agent_default") { - if (input.workspaceConfig.workspaceRuntime === null) { + if (input.workspaceConfig?.workspaceRuntime === null) { delete nextConfig.workspaceRuntime; - } else { + } else if (input.workspaceConfig?.workspaceRuntime) { nextConfig.workspaceRuntime = { ...input.workspaceConfig.workspaceRuntime }; + } else if (input.projectWorkspaceRuntime) { + nextConfig.workspaceRuntime = { ...input.projectWorkspaceRuntime }; } } - if (input.mode === "isolated_workspace") { + if (input.workspaceConfig && input.mode === "isolated_workspace") { const nextStrategy = parseObject(nextConfig.workspaceStrategy); if (input.workspaceConfig.provisionCommand === null) delete nextStrategy.provisionCommand; else nextStrategy.provisionCommand = input.workspaceConfig.provisionCommand; @@ -2112,14 +2114,32 @@ export function heartbeatService(db: Db) { : null; const existingExecutionWorkspace = issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null; + const resolvedProjectWorkspace = + resolvedWorkspace.workspaceId + ? await db + .select({ metadata: projectWorkspaces.metadata }) + .from(projectWorkspaces) + .where( + and( + eq(projectWorkspaces.id, resolvedWorkspace.workspaceId), + eq(projectWorkspaces.companyId, agent.companyId), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + const projectWorkspaceRuntimeConfig = readProjectWorkspaceRuntimeConfig( + (resolvedProjectWorkspace?.metadata as Record | null) ?? null, + ); const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({ config: workspaceManagedConfig, workspaceConfig: existingExecutionWorkspace?.config ?? null, + projectWorkspaceRuntime: projectWorkspaceRuntimeConfig?.workspaceRuntime ?? null, mode: executionWorkspaceMode, }); const mergedConfig = issueAssigneeOverrides?.adapterConfig ? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig } : persistedWorkspaceManagedConfig; + const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig); const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime( agent.companyId, mergedConfig, @@ -2129,7 +2149,6 @@ export function heartbeatService(db: Db) { ...resolvedConfig, paperclipRuntimeSkills: runtimeSkillEntries, }; - const configSnapshot = buildExecutionWorkspaceConfigSnapshot(resolvedConfig); const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({ companyId: agent.companyId, heartbeatRunId: run.id, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index fccd6c7f..241355b6 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -28,5 +28,5 @@ export { workProductService } from "./work-products.js"; export { logActivity, type LogActivityInput } from "./activity-log.js"; export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js"; export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js"; -export { reconcilePersistedRuntimeServicesOnStartup } from "./workspace-runtime.js"; +export { reconcilePersistedRuntimeServicesOnStartup, restartDesiredRuntimeServicesOnStartup } from "./workspace-runtime.js"; export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js"; diff --git a/server/src/services/project-workspace-runtime-config.ts b/server/src/services/project-workspace-runtime-config.ts new file mode 100644 index 00000000..8252fecd --- /dev/null +++ b/server/src/services/project-workspace-runtime-config.ts @@ -0,0 +1,59 @@ +import type { ProjectWorkspaceRuntimeConfig } from "@paperclipai/shared"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function cloneRecord(value: unknown): Record | null { + return isRecord(value) ? { ...value } : null; +} + +function readDesiredState(value: unknown): ProjectWorkspaceRuntimeConfig["desiredState"] { + return value === "running" || value === "stopped" ? value : null; +} + +export function readProjectWorkspaceRuntimeConfig( + metadata: Record | null | undefined, +): ProjectWorkspaceRuntimeConfig | null { + const raw = isRecord(metadata?.runtimeConfig) ? metadata.runtimeConfig : null; + if (!raw) return null; + + const config: ProjectWorkspaceRuntimeConfig = { + workspaceRuntime: cloneRecord(raw.workspaceRuntime), + desiredState: readDesiredState(raw.desiredState), + }; + + const hasConfig = config.workspaceRuntime !== null || config.desiredState !== null; + return hasConfig ? config : null; +} + +export function mergeProjectWorkspaceRuntimeConfig( + metadata: Record | null | undefined, + patch: Partial | null, +): Record | null { + const nextMetadata = isRecord(metadata) ? { ...metadata } : {}; + const current = readProjectWorkspaceRuntimeConfig(metadata) ?? { + workspaceRuntime: null, + desiredState: null, + }; + + if (patch === null) { + delete nextMetadata.runtimeConfig; + return Object.keys(nextMetadata).length > 0 ? nextMetadata : null; + } + + const nextConfig: ProjectWorkspaceRuntimeConfig = { + workspaceRuntime: + patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime, + desiredState: + patch.desiredState !== undefined ? readDesiredState(patch.desiredState) : current.desiredState, + }; + + if (nextConfig.workspaceRuntime === null && nextConfig.desiredState === null) { + delete nextMetadata.runtimeConfig; + } else { + nextMetadata.runtimeConfig = nextConfig; + } + + return Object.keys(nextMetadata).length > 0 ? nextMetadata : null; +} diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 4f7d1eb2..7c7c4bb0 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -9,11 +9,13 @@ import { type ProjectCodebase, type ProjectExecutionWorkspacePolicy, type ProjectGoalRef, + type ProjectWorkspaceRuntimeConfig, type ProjectWorkspace, type WorkspaceRuntimeService, } from "@paperclipai/shared"; import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; +import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; import { resolveManagedProjectWorkspaceDir } from "../home-paths.js"; type ProjectRow = typeof projects.$inferSelect; @@ -34,6 +36,7 @@ type CreateWorkspaceInput = { remoteWorkspaceRef?: string | null; sharedWorkspaceKey?: string | null; metadata?: Record | null; + runtimeConfig?: Partial | null; isPrimary?: boolean; }; type UpdateWorkspaceInput = Partial; @@ -149,6 +152,7 @@ function toWorkspace( remoteWorkspaceRef: row.remoteWorkspaceRef ?? null, sharedWorkspaceKey: row.sharedWorkspaceKey ?? null, metadata: (row.metadata as Record | null) ?? null, + runtimeConfig: readProjectWorkspaceRuntimeConfig((row.metadata as Record | null) ?? null), isPrimary: row.isPrimary, runtimeServices, createdAt: row.createdAt, @@ -611,7 +615,13 @@ export function projectService(db: Db) { remoteProvider: readNonEmptyString(data.remoteProvider), remoteWorkspaceRef, sharedWorkspaceKey: readNonEmptyString(data.sharedWorkspaceKey), - metadata: (data.metadata as Record | null | undefined) ?? null, + metadata: + data.runtimeConfig !== undefined + ? mergeProjectWorkspaceRuntimeConfig( + (data.metadata as Record | null | undefined) ?? null, + data.runtimeConfig ?? null, + ) + : (data.metadata as Record | null | undefined) ?? null, isPrimary: shouldBePrimary, }) .returning() @@ -681,7 +691,17 @@ export function projectService(db: Db) { if (data.remoteProvider !== undefined) patch.remoteProvider = readNonEmptyString(data.remoteProvider); if (data.remoteWorkspaceRef !== undefined) patch.remoteWorkspaceRef = nextRemoteWorkspaceRef; if (data.sharedWorkspaceKey !== undefined) patch.sharedWorkspaceKey = readNonEmptyString(data.sharedWorkspaceKey); - if (data.metadata !== undefined) patch.metadata = data.metadata; + if (data.metadata !== undefined || data.runtimeConfig !== undefined) { + patch.metadata = + data.runtimeConfig !== undefined + ? mergeProjectWorkspaceRuntimeConfig( + data.metadata !== undefined + ? (data.metadata as Record | null | undefined) + : ((existing.metadata as Record | null | undefined) ?? null), + data.runtimeConfig ?? null, + ) + : data.metadata; + } const updated = await db.transaction(async (tx) => { if (data.isPrimary === true) { diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 9991a66d..49fb6f65 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -6,7 +6,7 @@ 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 { executionWorkspaces, projectWorkspaces, 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"; @@ -21,6 +21,8 @@ import { writeLocalServiceRegistryRecord, } from "./local-service-supervisor.js"; import type { WorkspaceOperationRecorder } from "./workspace-operations.js"; +import { readExecutionWorkspaceConfig } from "./execution-workspaces.js"; +import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; export interface ExecutionWorkspaceInput { baseCwd: string; @@ -38,7 +40,7 @@ export interface ExecutionWorkspaceIssueRef { } export interface ExecutionWorkspaceAgentRef { - id: string; + id: string | null; name: string; companyId: string; } @@ -211,7 +213,7 @@ function renderWorkspaceTemplate(template: string, input: { title: input.issue?.title ?? "", }, agent: { - id: input.agent.id, + id: input.agent.id ?? "", name: input.agent.name, }, project: { @@ -334,7 +336,7 @@ function buildWorkspaceCommandEnv(input: { 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_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 ?? ""; @@ -903,7 +905,7 @@ function buildTemplateData(input: { title: input.issue?.title ?? "", }, agent: { - id: input.agent.id, + id: input.agent.id ?? "", name: input.agent.name, }, port: input.port ?? "", @@ -1091,7 +1093,7 @@ export function normalizeAdapterManagedRuntimeServices(input: { url: report.url ?? null, provider: "adapter_managed", providerRef: report.providerRef ?? null, - ownerAgentId: report.ownerAgentId ?? input.agent.id, + ownerAgentId: report.ownerAgentId ?? input.agent.id ?? null, startedByRunId: input.runId, lastUsedAt: nowIso, startedAt: nowIso, @@ -1203,7 +1205,7 @@ async function startLocalRuntimeService(input: { url: adoptedRecord.url ?? url, provider: "local_process", providerRef: String(adoptedRecord.pid), - ownerAgentId: input.agent.id, + ownerAgentId: input.agent.id ?? null, startedByRunId: input.runId, lastUsedAt: new Date().toISOString(), startedAt: adoptedRecord.startedAt, @@ -1277,7 +1279,7 @@ async function startLocalRuntimeService(input: { url, provider: "local_process", providerRef: child.pid ? String(child.pid) : null, - ownerAgentId: input.agent.id, + ownerAgentId: input.agent.id ?? null, startedByRunId: input.runId, lastUsedAt: new Date().toISOString(), startedAt: new Date().toISOString(), @@ -1345,7 +1347,10 @@ async function stopRuntimeService(serviceId: string) { record.lastUsedAt = new Date().toISOString(); record.stoppedAt = new Date().toISOString(); if (record.child && record.child.pid) { - terminateChildProcess(record.child); + await terminateLocalService({ + pid: record.child.pid, + processGroupId: record.processGroupId ?? record.child.pid, + }); } else if (record.providerRef) { const pid = Number.parseInt(record.providerRef, 10); if (Number.isInteger(pid) && pid > 0) { @@ -1409,6 +1414,13 @@ function registerRuntimeService(db: Db | undefined, record: RuntimeServiceRecord }); } +function readRuntimeServiceEntries(config: Record) { + const runtime = parseObject(config.workspaceRuntime); + return Array.isArray(runtime.services) + ? runtime.services.filter((entry): entry is Record => typeof entry === "object" && entry !== null) + : []; +} + export async function ensureRuntimeServicesForRun(input: { db?: Db; runId: string; @@ -1420,10 +1432,7 @@ export async function ensureRuntimeServicesForRun(input: { 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 rawServices = readRuntimeServiceEntries(input.config); const acquiredServiceIds: string[] = []; const refs: RuntimeServiceRef[] = []; runtimeServiceLeasesByRun.set(input.runId, acquiredServiceIds); @@ -1493,6 +1502,79 @@ export async function ensureRuntimeServicesForRun(input: { return refs; } +export async function startRuntimeServicesForWorkspaceControl(input: { + db?: Db; + invocationId?: string; + actor: ExecutionWorkspaceAgentRef; + issue: ExecutionWorkspaceIssueRef | null; + workspace: RealizedExecutionWorkspace; + executionWorkspaceId?: string | null; + config: Record; + adapterEnv: Record; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; +}): Promise { + const rawServices = readRuntimeServiceEntries(input.config); + const refs: RuntimeServiceRef[] = []; + const invocationId = input.invocationId ?? randomUUID(); + + 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: invocationId, + agent: input.actor, + }); + 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.lastUsedAt = new Date().toISOString(); + existing.stoppedAt = null; + clearIdleTimer(existing); + void touchLocalServiceRegistryRecord(existing.serviceKey, { + runtimeServiceId: existing.id, + lastSeenAt: existing.lastUsedAt, + }); + await persistRuntimeServiceRecord(input.db, existing); + refs.push(toRuntimeServiceRef(existing, { reused: true })); + continue; + } + } + + const record = await startLocalRuntimeService({ + db: input.db, + runId: invocationId, + agent: input.actor, + issue: input.issue, + workspace: input.workspace, + executionWorkspaceId: input.executionWorkspaceId, + adapterEnv: input.adapterEnv, + service, + onLog: input.onLog, + reuseKey, + scopeType, + scopeId, + }); + record.startedByRunId = null; + registerRuntimeService(input.db, record); + await persistRuntimeServiceRecord(input.db, record); + refs.push(toRuntimeServiceRef(record)); + } + + return refs; +} + export async function releaseRuntimeServicesForRun(runId: string) { const acquired = runtimeServiceLeasesByRun.get(runId) ?? []; runtimeServiceLeasesByRun.delete(runId); @@ -1543,6 +1625,39 @@ export async function stopRuntimeServicesForExecutionWorkspace(input: { } } +export async function stopRuntimeServicesForProjectWorkspace(input: { + db?: Db; + projectWorkspaceId: string; +}) { + const matchingServiceIds = Array.from(runtimeServicesById.values()) + .filter((record) => record.projectWorkspaceId === input.projectWorkspaceId && record.scopeType === "project_workspace") + .map((record) => record.id); + + for (const serviceId of matchingServiceIds) { + await stopRuntimeService(serviceId); + } + + if (input.db) { + const now = new Date(); + await input.db + .update(workspaceRuntimeServices) + .set({ + status: "stopped", + healthStatus: "unknown", + stoppedAt: now, + lastUsedAt: now, + updatedAt: now, + }) + .where( + and( + eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId), + eq(workspaceRuntimeServices.scopeType, "project_workspace"), + inArray(workspaceRuntimeServices.status, ["starting", "running"]), + ), + ); + } +} + export async function listWorkspaceRuntimeServicesForProjectWorkspaces( db: Db, companyId: string, @@ -1556,6 +1671,7 @@ export async function listWorkspaceRuntimeServicesForProjectWorkspaces( and( eq(workspaceRuntimeServices.companyId, companyId), inArray(workspaceRuntimeServices.projectWorkspaceId, projectWorkspaceIds), + eq(workspaceRuntimeServices.scopeType, "project_workspace"), ), ) .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); @@ -1661,6 +1777,93 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) { return { reconciled: rows.length, adopted, stopped }; } +export async function restartDesiredRuntimeServicesOnStartup(db: Db) { + let restarted = 0; + let failed = 0; + + const projectWorkspaceRows = await db + .select() + .from(projectWorkspaces); + + for (const row of projectWorkspaceRows) { + const runtimeConfig = readProjectWorkspaceRuntimeConfig((row.metadata as Record | null) ?? null); + if (runtimeConfig?.desiredState !== "running" || !runtimeConfig.workspaceRuntime || !row.cwd) continue; + + try { + const refs = await startRuntimeServicesForWorkspaceControl({ + db, + actor: { id: null, name: "Paperclip", companyId: row.companyId }, + issue: null, + workspace: { + baseCwd: row.cwd, + source: "project_primary", + projectId: row.projectId, + workspaceId: row.id, + repoUrl: row.repoUrl ?? null, + repoRef: row.repoRef ?? null, + strategy: "project_primary", + cwd: row.cwd, + branchName: row.defaultRef ?? row.repoRef ?? null, + worktreePath: null, + warnings: [], + created: false, + }, + config: { workspaceRuntime: runtimeConfig.workspaceRuntime }, + adapterEnv: {}, + }); + if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length; + } catch { + failed += 1; + } + } + + const executionWorkspaceRows = await db + .select() + .from(executionWorkspaces) + .where(inArray(executionWorkspaces.status, ["active", "idle", "in_review", "cleanup_failed"])); + + for (const row of executionWorkspaceRows) { + const config = readExecutionWorkspaceConfig((row.metadata as Record | null) ?? null); + if (config?.desiredState !== "running" || !config.workspaceRuntime || !row.cwd) continue; + + try { + const refs = await startRuntimeServicesForWorkspaceControl({ + db, + actor: { id: null, name: "Paperclip", companyId: row.companyId }, + issue: row.sourceIssueId + ? { + id: row.sourceIssueId, + identifier: null, + title: row.name, + } + : null, + workspace: { + baseCwd: row.cwd, + source: row.mode === "shared_workspace" ? "project_primary" : "task_session", + projectId: row.projectId, + workspaceId: row.projectWorkspaceId ?? null, + repoUrl: row.repoUrl ?? null, + repoRef: row.baseRef ?? null, + strategy: row.strategyType === "git_worktree" ? "git_worktree" : "project_primary", + cwd: row.cwd, + branchName: row.branchName ?? null, + worktreePath: row.strategyType === "git_worktree" ? row.cwd : null, + warnings: [], + created: false, + }, + executionWorkspaceId: row.id, + config: { workspaceRuntime: config.workspaceRuntime }, + adapterEnv: {}, + }); + if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length; + } catch { + failed += 1; + } + } + + return { restarted, failed }; +} + export async function persistAdapterManagedRuntimeServices(input: { db: Db; adapterType: string; diff --git a/ui/src/api/execution-workspaces.ts b/ui/src/api/execution-workspaces.ts index 59a447ca..3644af77 100644 --- a/ui/src/api/execution-workspaces.ts +++ b/ui/src/api/execution-workspaces.ts @@ -1,4 +1,4 @@ -import type { ExecutionWorkspace, ExecutionWorkspaceCloseReadiness } from "@paperclipai/shared"; +import type { ExecutionWorkspace, ExecutionWorkspaceCloseReadiness, WorkspaceOperation } from "@paperclipai/shared"; import { api } from "./client"; export const executionWorkspacesApi = { @@ -24,5 +24,12 @@ export const executionWorkspacesApi = { get: (id: string) => api.get(`/execution-workspaces/${id}`), getCloseReadiness: (id: string) => api.get(`/execution-workspaces/${id}/close-readiness`), + listWorkspaceOperations: (id: string) => + api.get(`/execution-workspaces/${id}/workspace-operations`), + controlRuntimeServices: (id: string, action: "start" | "stop" | "restart") => + api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>( + `/execution-workspaces/${id}/runtime-services/${action}`, + {}, + ), update: (id: string, data: Record) => api.patch(`/execution-workspaces/${id}`, data), }; diff --git a/ui/src/api/projects.ts b/ui/src/api/projects.ts index c7177ac6..763718ff 100644 --- a/ui/src/api/projects.ts +++ b/ui/src/api/projects.ts @@ -1,4 +1,4 @@ -import type { Project, ProjectWorkspace } from "@paperclipai/shared"; +import type { Project, ProjectWorkspace, WorkspaceOperation } from "@paperclipai/shared"; import { api } from "./client"; function withCompanyScope(path: string, companyId?: string) { @@ -27,6 +27,16 @@ export const projectsApi = { projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`), data, ), + controlWorkspaceRuntimeServices: ( + projectId: string, + workspaceId: string, + action: "start" | "stop" | "restart", + companyId?: string, + ) => + api.post<{ workspace: ProjectWorkspace; operation: WorkspaceOperation }>( + projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}/runtime-services/${action}`), + {}, + ), removeWorkspace: (projectId: string, workspaceId: string, companyId?: string) => api.delete(projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`)), remove: (id: string, companyId?: string) => api.delete(projectPath(id, companyId)), diff --git a/ui/src/lib/project-workspaces-tab.test.ts b/ui/src/lib/project-workspaces-tab.test.ts index 0e07e7c6..0dcb70c8 100644 --- a/ui/src/lib/project-workspaces-tab.test.ts +++ b/ui/src/lib/project-workspaces-tab.test.ts @@ -20,6 +20,7 @@ function createProjectWorkspace(overrides: Partial): ProjectWo remoteWorkspaceRef: overrides.remoteWorkspaceRef ?? null, sharedWorkspaceKey: overrides.sharedWorkspaceKey ?? null, metadata: overrides.metadata ?? null, + runtimeConfig: overrides.runtimeConfig ?? null, isPrimary: overrides.isPrimary ?? false, runtimeServices: overrides.runtimeServices ?? [], createdAt: overrides.createdAt ?? new Date("2026-03-20T00:00:00Z"), @@ -151,7 +152,7 @@ describe("buildProjectWorkspaceSummaries", () => { ], }); - expect(summaries).toHaveLength(2); + expect(summaries).toHaveLength(3); expect(summaries[0]).toMatchObject({ key: "execution:exec-1", kind: "execution_workspace", @@ -172,6 +173,7 @@ describe("buildProjectWorkspaceSummaries", () => { "issue-feature-newer", "issue-feature-older", ]); + expect(summaries[2]?.key).toBe("project:workspace-default"); }); it("does not duplicate non-primary workspace issues when an execution workspace owns them", () => { @@ -194,8 +196,9 @@ describe("buildProjectWorkspaceSummaries", () => { ], }); - expect(summaries).toHaveLength(1); + expect(summaries).toHaveLength(2); expect(summaries[0]?.key).toBe("execution:exec-2"); + expect(summaries[1]?.key).toBe("project:workspace-default"); }); it("excludes issues that only use the default shared workspace", () => { @@ -222,6 +225,7 @@ describe("buildProjectWorkspaceSummaries", () => { ], }); - expect(summaries).toHaveLength(0); + expect(summaries).toHaveLength(1); + expect(summaries[0]?.key).toBe("project:workspace-default"); }); }); diff --git a/ui/src/lib/project-workspaces-tab.ts b/ui/src/lib/project-workspaces-tab.ts index ca466461..df0d9bb3 100644 --- a/ui/src/lib/project-workspaces-tab.ts +++ b/ui/src/lib/project-workspaces-tab.ts @@ -13,6 +13,10 @@ export interface ProjectWorkspaceSummary { projectWorkspaceId: string | null; executionWorkspaceId: string | null; executionWorkspaceStatus: ExecutionWorkspace["status"] | null; + serviceCount: number; + runningServiceCount: number; + primaryServiceUrl: string | null; + hasRuntimeConfig: boolean; issues: Issue[]; } @@ -94,6 +98,13 @@ export function buildProjectWorkspaceSummaries(input: { projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null, executionWorkspaceId: executionWorkspace.id, executionWorkspaceStatus: executionWorkspace.status, + serviceCount: executionWorkspace.runtimeServices?.length ?? 0, + runningServiceCount: executionWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0, + primaryServiceUrl: executionWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null, + hasRuntimeConfig: Boolean( + executionWorkspace.config?.workspaceRuntime + ?? projectWorkspacesById.get(executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? "")?.runtimeConfig?.workspaceRuntime, + ), issues: nextIssues, }); continue; @@ -119,10 +130,41 @@ export function buildProjectWorkspaceSummaries(input: { projectWorkspaceId: projectWorkspace.id, executionWorkspaceId: null, executionWorkspaceStatus: null, + serviceCount: projectWorkspace.runtimeServices?.length ?? 0, + runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0, + primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null, + hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime), issues: nextIssues, }); } + for (const projectWorkspace of input.project.workspaces) { + const key = `project:${projectWorkspace.id}`; + if (summaries.has(key)) continue; + const shouldSurfaceWorkspace = + projectWorkspace.isPrimary + || Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime) + || (projectWorkspace.runtimeServices?.length ?? 0) > 0; + if (!shouldSurfaceWorkspace) continue; + summaries.set(key, { + key, + kind: "project_workspace", + workspaceId: projectWorkspace.id, + workspaceName: projectWorkspace.name, + cwd: projectWorkspace.cwd ?? null, + branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null, + lastUpdatedAt: maxDate(projectWorkspace.updatedAt), + projectWorkspaceId: projectWorkspace.id, + executionWorkspaceId: null, + executionWorkspaceStatus: null, + serviceCount: projectWorkspace.runtimeServices?.length ?? 0, + runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0, + primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null, + hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime), + issues: [], + }); + } + return [...summaries.values()].sort((a, b) => { const diff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime(); return diff !== 0 ? diff : a.workspaceName.localeCompare(b.workspaceName); diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index d54d6bf0..582a6177 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -62,6 +62,7 @@ export const queryKeys = { ["execution-workspaces", companyId, filters ?? {}] as const, detail: (id: string) => ["execution-workspaces", "detail", id] as const, closeReadiness: (id: string) => ["execution-workspaces", "close-readiness", id] as const, + workspaceOperations: (id: string) => ["execution-workspaces", "workspace-operations", id] as const, }, projects: { list: (companyId: string) => ["projects", companyId] as const, diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index ec483db7..603b1906 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -25,6 +25,7 @@ type WorkspaceFormState = { provisionCommand: string; teardownCommand: string; cleanupCommand: string; + inheritRuntime: boolean; workspaceRuntime: string; }; @@ -84,6 +85,7 @@ function formStateFromWorkspace(workspace: ExecutionWorkspace): WorkspaceFormSta provisionCommand: readText(workspace.config?.provisionCommand), teardownCommand: readText(workspace.config?.teardownCommand), cleanupCommand: readText(workspace.config?.cleanupCommand), + inheritRuntime: !workspace.config?.workspaceRuntime, workspaceRuntime: formatJson(workspace.config?.workspaceRuntime), }; } @@ -115,10 +117,10 @@ function buildWorkspacePatch(initialState: WorkspaceFormState, nextState: Worksp maybeAssignConfigText("teardownCommand"); maybeAssignConfigText("cleanupCommand"); - if (initialState.workspaceRuntime !== nextState.workspaceRuntime) { + if (initialState.inheritRuntime !== nextState.inheritRuntime || initialState.workspaceRuntime !== nextState.workspaceRuntime) { const parsed = parseWorkspaceRuntimeJson(nextState.workspaceRuntime); if (!parsed.ok) throw new Error(parsed.error); - configPatch.workspaceRuntime = parsed.value; + configPatch.workspaceRuntime = nextState.inheritRuntime ? null : parsed.value; } if (Object.keys(configPatch).length > 0) { @@ -138,9 +140,11 @@ function validateForm(form: WorkspaceFormState) { } } - const runtimeJson = parseWorkspaceRuntimeJson(form.workspaceRuntime); - if (!runtimeJson.ok) { - return runtimeJson.error; + if (!form.inheritRuntime) { + const runtimeJson = parseWorkspaceRuntimeJson(form.workspaceRuntime); + if (!runtimeJson.ok) { + return runtimeJson.error; + } } return null; @@ -214,6 +218,7 @@ export function ExecutionWorkspaceDetail() { const [form, setForm] = useState(null); const [closeDialogOpen, setCloseDialogOpen] = useState(false); const [errorMessage, setErrorMessage] = useState(null); + const [runtimeActionMessage, setRuntimeActionMessage] = useState(null); const workspaceQuery = useQuery({ queryKey: queryKeys.executionWorkspaces.detail(workspaceId!), @@ -249,6 +254,14 @@ export function ExecutionWorkspaceDetail() { () => project?.workspaces.find((item) => item.id === workspace?.projectWorkspaceId) ?? null, [project, workspace?.projectWorkspaceId], ); + const inheritedRuntimeConfig = linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime ?? null; + const effectiveRuntimeConfig = workspace?.config?.workspaceRuntime ?? inheritedRuntimeConfig; + const runtimeConfigSource = + workspace?.config?.workspaceRuntime + ? "execution_workspace" + : inheritedRuntimeConfig + ? "project_workspace" + : "none"; const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]); const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState)); @@ -281,6 +294,7 @@ export function ExecutionWorkspaceDetail() { onSuccess: (nextWorkspace) => { queryClient.setQueryData(queryKeys.executionWorkspaces.detail(nextWorkspace.id), nextWorkspace); queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(nextWorkspace.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(nextWorkspace.id) }); if (project) { queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.urlKey) }); @@ -294,6 +308,32 @@ export function ExecutionWorkspaceDetail() { setErrorMessage(error instanceof Error ? error.message : "Failed to save execution workspace."); }, }); + const workspaceOperationsQuery = useQuery({ + queryKey: queryKeys.executionWorkspaces.workspaceOperations(workspaceId!), + queryFn: () => executionWorkspacesApi.listWorkspaceOperations(workspaceId!), + enabled: Boolean(workspaceId), + }); + const controlRuntimeServices = useMutation({ + mutationFn: (action: "start" | "stop" | "restart") => + executionWorkspacesApi.controlRuntimeServices(workspace!.id, action), + onSuccess: (result, action) => { + queryClient.setQueryData(queryKeys.executionWorkspaces.detail(result.workspace.id), result.workspace); + queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(result.workspace.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(result.workspace.projectId) }); + setErrorMessage(null); + setRuntimeActionMessage( + action === "stop" + ? "Runtime services stopped." + : action === "restart" + ? "Runtime services restarted." + : "Runtime services started.", + ); + }, + onError: (error) => { + setRuntimeActionMessage(null); + setErrorMessage(error instanceof Error ? error.message : "Failed to control runtime services."); + }, + }); if (workspaceQuery.isLoading) return

Loading workspace…

; if (workspaceQuery.error) { @@ -455,11 +495,54 @@ export function ExecutionWorkspaceDetail() { /> - +
+
+
+
+ Runtime config source +
+

+ {runtimeConfigSource === "execution_workspace" + ? "This execution workspace currently overrides the project workspace runtime config." + : runtimeConfigSource === "project_workspace" + ? "This execution workspace is inheriting the project workspace runtime config." + : "No runtime config is currently defined on this execution workspace or its project workspace."} +

+
+ +
+
+ + +
+ + setForm((current) => current ? { ...current, inheritRuntime: event.target.checked } : current) + } + /> + +