From e25a58d1a037ce245121de6f7bda7a5e679311d3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 12 Apr 2026 07:26:32 -0500 Subject: [PATCH] Refine workspace command controls --- ...ecution-workspaces-and-runtime-services.md | 28 +- packages/shared/src/index.ts | 13 + packages/shared/src/types/index.ts | 4 + .../shared/src/types/workspace-runtime.ts | 30 ++ .../src/validators/execution-workspace.ts | 8 + packages/shared/src/validators/project.ts | 1 + .../shared/src/workspace-commands.test.ts | 56 +++ packages/shared/src/workspace-commands.ts | 204 ++++++++ .../src/__tests__/workspace-runtime.test.ts | 186 +++++++ server/src/routes/execution-workspaces.ts | 162 +++++- server/src/routes/projects.ts | 156 +++++- server/src/services/execution-workspaces.ts | 15 + .../project-workspace-runtime-config.ts | 15 +- server/src/services/workspace-runtime.ts | 239 ++++++++- ui/src/api/execution-workspaces.ts | 24 +- ui/src/api/projects.ts | 21 +- .../WorkspaceRuntimeControls.test.tsx | 151 ++++-- .../components/WorkspaceRuntimeControls.tsx | 461 +++++++++++++++--- ui/src/pages/ExecutionWorkspaceDetail.tsx | 101 ++-- ui/src/pages/ProjectWorkspaceDetail.tsx | 67 ++- 20 files changed, 1720 insertions(+), 222 deletions(-) create mode 100644 packages/shared/src/workspace-commands.test.ts create mode 100644 packages/shared/src/workspace-commands.ts diff --git a/docs/guides/board-operator/execution-workspaces-and-runtime-services.md b/docs/guides/board-operator/execution-workspaces-and-runtime-services.md index 285d701a489..cd8a31c8cb1 100644 --- a/docs/guides/board-operator/execution-workspaces-and-runtime-services.md +++ b/docs/guides/board-operator/execution-workspaces-and-runtime-services.md @@ -5,22 +5,28 @@ summary: How project runtime configuration, execution workspaces, and issue runs This guide documents the intended runtime model for projects, execution workspaces, and issue runs in Paperclip. +Paperclip now presents this as a workspace-command model: + +- `Services` are long-running commands that stay supervised. +- `Jobs` are one-shot commands that run once and exit. +- Raw runtime JSON is still available for advanced config, but it is no longer the primary mental model. + ## Project runtime configuration You can define how to run a project on the project workspace itself. -- Project workspace runtime config describes how to run services for that project checkout. +- Project workspace runtime config describes the services and jobs available for that project checkout. - This is the default runtime configuration that child execution workspaces may inherit. - Defining the config does not start anything by itself. ## Manual runtime control -Runtime services are manually controlled from the UI. +Workspace commands are manually controlled from the UI. -- Project workspace runtime services are started and stopped from the project workspace UI. -- Execution workspace runtime services are started and stopped from the execution workspace UI. -- Paperclip does not automatically start or stop these runtime services as part of issue execution. -- Paperclip also does not automatically restart workspace runtime services on server boot. +- Project workspace services are started and stopped from the project workspace UI, and project jobs can be run on demand there. +- Execution workspace services are started and stopped from the execution workspace UI, and execution-workspace jobs can be run on demand there. +- Paperclip does not automatically start or stop these workspace services as part of issue execution. +- Paperclip also does not automatically restart workspace services on server boot. ## Execution workspace inheritance @@ -29,7 +35,7 @@ Execution workspaces isolate code and runtime state from the project primary wor - An isolated execution workspace has its own checkout path, branch, and local runtime instance. - The runtime configuration may inherit from the linked project workspace by default. - The execution workspace may override that runtime configuration with its own workspace-specific settings. -- The inherited configuration answers "how to run the service", but the running process is still specific to that execution workspace. +- The inherited configuration answers "which commands exist and how to run them", but any running service process is still specific to that execution workspace. ## Issues and execution workspaces @@ -38,7 +44,7 @@ Issues are attached to execution workspace behavior, not to automatic runtime ma - An issue may create a new execution workspace when you choose an isolated workspace mode. - An issue may reuse an existing execution workspace when you choose reuse. - Multiple issues may intentionally share one execution workspace so they can work against the same branch and running runtime services. -- Assigning or running an issue does not automatically start or stop runtime services for that workspace. +- Assigning or running an issue does not automatically start or stop workspace services for that workspace. ## Execution workspace lifecycle @@ -62,7 +68,7 @@ Heartbeat still resolves a workspace for the run, but that is about code locatio With the current implementation: -- Project workspace runtime config is the fallback for execution workspace UI controls. +- Project workspace command config is the fallback for execution workspace UI controls. - Execution workspace runtime overrides are stored on the execution workspace. -- Heartbeat runs do not auto-start workspace runtime services. -- Server startup does not auto-restart workspace runtime services. +- Heartbeat runs do not auto-start workspace services. +- Server startup does not auto-restart workspace services. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8ec63026c97..3320fb71d20 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -232,7 +232,11 @@ export type { ExecutionWorkspaceCloseReadiness, ExecutionWorkspaceCloseReadinessState, ProjectWorkspaceRuntimeConfig, + WorkspaceCommandDefinition, + WorkspaceCommandKind, + WorkspaceRuntimeControlTarget, WorkspaceRuntimeService, + WorkspaceRuntimeServiceStateMap, WorkspaceOperation, WorkspaceOperationPhase, WorkspaceOperationStatus, @@ -374,6 +378,15 @@ export type { ProviderQuotaResult, } from "./types/index.js"; +export { workspaceRuntimeControlTargetSchema } from "./validators/execution-workspace.js"; +export { + findWorkspaceCommandDefinition, + listWorkspaceCommandDefinitions, + listWorkspaceServiceCommandDefinitions, + matchWorkspaceRuntimeServiceToCommand, + scoreWorkspaceRuntimeServiceMatch, +} from "./workspace-commands.js"; + export { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, FEEDBACK_TARGET_TYPES, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 13625a68c03..5c09ae1abc8 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -71,7 +71,11 @@ export type { ExecutionWorkspaceCloseReadiness, ExecutionWorkspaceCloseReadinessState, ProjectWorkspaceRuntimeConfig, + WorkspaceCommandDefinition, + WorkspaceCommandKind, + WorkspaceRuntimeControlTarget, WorkspaceRuntimeService, + WorkspaceRuntimeServiceStateMap, WorkspaceRuntimeDesiredState, ExecutionWorkspaceStrategyType, ExecutionWorkspaceMode, diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 2b2c4e2d433..9e287200fa8 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -46,6 +46,27 @@ export type ExecutionWorkspaceCloseActionKind = | "remove_local_directory"; export type WorkspaceRuntimeDesiredState = "running" | "stopped"; +export type WorkspaceRuntimeServiceStateMap = Record; +export type WorkspaceCommandKind = "service" | "job"; + +export interface WorkspaceCommandSource { + type: "paperclip"; + key: "commands" | "services" | "jobs"; + index: number; +} + +export interface WorkspaceCommandDefinition { + id: string; + name: string; + kind: WorkspaceCommandKind; + command: string | null; + cwd: string | null; + lifecycle: "shared" | "ephemeral" | null; + serviceIndex: number | null; + disabledReason: string | null; + rawConfig: Record; + source: WorkspaceCommandSource; +} export interface ExecutionWorkspaceStrategy { type: ExecutionWorkspaceStrategyType; @@ -62,11 +83,19 @@ export interface ExecutionWorkspaceConfig { cleanupCommand: string | null; workspaceRuntime: Record | null; desiredState: WorkspaceRuntimeDesiredState | null; + serviceStates?: WorkspaceRuntimeServiceStateMap | null; } export interface ProjectWorkspaceRuntimeConfig { workspaceRuntime: Record | null; desiredState: WorkspaceRuntimeDesiredState | null; + serviceStates?: WorkspaceRuntimeServiceStateMap | null; +} + +export interface WorkspaceRuntimeControlTarget { + workspaceCommandId?: string | null; + runtimeServiceId?: string | null; + serviceIndex?: number | null; } export interface ExecutionWorkspaceCloseAction { @@ -187,6 +216,7 @@ export interface WorkspaceRuntimeService { stoppedAt: Date | null; stopPolicy: Record | null; healthStatus: "unknown" | "healthy" | "unhealthy"; + configIndex?: number | null; createdAt: Date; updatedAt: Date; } diff --git a/packages/shared/src/validators/execution-workspace.ts b/packages/shared/src/validators/execution-workspace.ts index 9914d74ec38..fad63823808 100644 --- a/packages/shared/src/validators/execution-workspace.ts +++ b/packages/shared/src/validators/execution-workspace.ts @@ -14,6 +14,13 @@ export const executionWorkspaceConfigSchema = z.object({ cleanupCommand: z.string().optional().nullable(), workspaceRuntime: z.record(z.unknown()).optional().nullable(), desiredState: z.enum(["running", "stopped"]).optional().nullable(), + serviceStates: z.record(z.enum(["running", "stopped"])).optional().nullable(), +}).strict(); + +export const workspaceRuntimeControlTargetSchema = z.object({ + workspaceCommandId: z.string().min(1).optional().nullable(), + runtimeServiceId: z.string().uuid().optional().nullable(), + serviceIndex: z.number().int().nonnegative().optional().nullable(), }).strict(); export const executionWorkspaceCloseReadinessStateSchema = z.enum([ @@ -88,6 +95,7 @@ export const workspaceRuntimeServiceSchema = z.object({ stoppedAt: z.coerce.date().nullable(), stopPolicy: z.record(z.unknown()).nullable(), healthStatus: z.enum(["unknown", "healthy", "unhealthy"]), + configIndex: z.number().int().nonnegative().nullable().optional(), createdAt: z.coerce.date(), updatedAt: z.coerce.date(), }).strict(); diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index f0030fb7194..8e8549ca2e1 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -31,6 +31,7 @@ export const projectExecutionWorkspacePolicySchema = z export const projectWorkspaceRuntimeConfigSchema = z.object({ workspaceRuntime: z.record(z.unknown()).optional().nullable(), desiredState: z.enum(["running", "stopped"]).optional().nullable(), + serviceStates: z.record(z.enum(["running", "stopped"])).optional().nullable(), }).strict(); const projectWorkspaceSourceTypeSchema = z.enum(["local_path", "git_repo", "remote_managed", "non_git_path"]); diff --git a/packages/shared/src/workspace-commands.test.ts b/packages/shared/src/workspace-commands.test.ts new file mode 100644 index 00000000000..1fc74478cda --- /dev/null +++ b/packages/shared/src/workspace-commands.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + findWorkspaceCommandDefinition, + listWorkspaceCommandDefinitions, + matchWorkspaceRuntimeServiceToCommand, +} from "./workspace-commands.js"; + +describe("workspace command helpers", () => { + it("derives service and job commands from command-first runtime config", () => { + const commands = listWorkspaceCommandDefinitions({ + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev" }, + { id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" }, + ], + }); + + expect(commands).toEqual([ + expect.objectContaining({ id: "web", kind: "service", serviceIndex: 0 }), + expect.objectContaining({ id: "db-migrate", kind: "job", serviceIndex: null }), + ]); + }); + + it("falls back to legacy services and jobs arrays", () => { + const commands = listWorkspaceCommandDefinitions({ + services: [{ name: "web", command: "pnpm dev" }], + jobs: [{ name: "lint", command: "pnpm lint" }], + }); + + expect(commands).toEqual([ + expect.objectContaining({ id: "service:web", kind: "service", serviceIndex: 0 }), + expect.objectContaining({ id: "job:lint", kind: "job", serviceIndex: null }), + ]); + }); + + it("matches a configured service command to the current runtime service", () => { + const workspaceRuntime = { + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev", cwd: "." }, + ], + }; + const command = findWorkspaceCommandDefinition(workspaceRuntime, "web"); + expect(command).not.toBeNull(); + + const match = matchWorkspaceRuntimeServiceToCommand(command!, [ + { + id: "runtime-web", + serviceName: "web", + command: "pnpm dev", + cwd: "/repo", + configIndex: null, + }, + ]); + + expect(match).toEqual(expect.objectContaining({ id: "runtime-web" })); + }); +}); diff --git a/packages/shared/src/workspace-commands.ts b/packages/shared/src/workspace-commands.ts new file mode 100644 index 00000000000..0510d3fbc65 --- /dev/null +++ b/packages/shared/src/workspace-commands.ts @@ -0,0 +1,204 @@ +import type { WorkspaceCommandDefinition, WorkspaceRuntimeService } from "./types/workspace-runtime.js"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function slugify(value: string | null | undefined) { + const normalized = (value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized.length > 0 ? normalized : null; +} + +function deriveWorkspaceCommandId(input: { + kind: WorkspaceCommandDefinition["kind"]; + explicitId: string | null; + name: string; + index: number; +}) { + const explicitId = slugify(input.explicitId); + if (explicitId) return explicitId; + const nameSlug = slugify(input.name); + return nameSlug ? `${input.kind}:${nameSlug}` : `${input.kind}:${input.index + 1}`; +} + +function buildWorkspaceCommandDefinition(input: { + entry: Record; + kind: WorkspaceCommandDefinition["kind"]; + sourceKey: WorkspaceCommandDefinition["source"]["key"]; + sourceIndex: number; + serviceIndex: number | null; + fallbackName: string; +}): WorkspaceCommandDefinition { + return { + id: deriveWorkspaceCommandId({ + kind: input.kind, + explicitId: readNonEmptyString(input.entry.id), + name: + readNonEmptyString(input.entry.name) + ?? readNonEmptyString(input.entry.label) + ?? readNonEmptyString(input.entry.title) + ?? input.fallbackName, + index: input.sourceIndex, + }), + name: + readNonEmptyString(input.entry.name) + ?? readNonEmptyString(input.entry.label) + ?? readNonEmptyString(input.entry.title) + ?? input.fallbackName, + kind: input.kind, + command: readNonEmptyString(input.entry.command), + cwd: readNonEmptyString(input.entry.cwd), + lifecycle: + input.kind === "service" + ? input.entry.lifecycle === "ephemeral" + ? "ephemeral" + : "shared" + : null, + serviceIndex: input.serviceIndex, + disabledReason: readNonEmptyString(input.entry.disabledReason), + rawConfig: { ...input.entry }, + source: { + type: "paperclip", + key: input.sourceKey, + index: input.sourceIndex, + }, + }; +} + +function uniqueWorkspaceCommandId( + seen: Set, + commandId: string, + sourceKey: WorkspaceCommandDefinition["source"]["key"], + sourceIndex: number, +) { + if (!seen.has(commandId)) { + seen.add(commandId); + return commandId; + } + const fallbackId = `${commandId}-${sourceKey}-${sourceIndex + 1}`; + seen.add(fallbackId); + return fallbackId; +} + +function readCommandEntries( + workspaceRuntime: Record | null | undefined, + key: "commands" | "services" | "jobs", +) { + const raw = workspaceRuntime?.[key]; + return Array.isArray(raw) ? raw.filter((entry): entry is Record => isRecord(entry)) : []; +} + +export function listWorkspaceCommandDefinitions( + workspaceRuntime: Record | null | undefined, +): WorkspaceCommandDefinition[] { + if (!workspaceRuntime) return []; + + const commandEntries = readCommandEntries(workspaceRuntime, "commands"); + const seenIds = new Set(); + let nextServiceIndex = 0; + + const finalize = (command: WorkspaceCommandDefinition) => ({ + ...command, + id: uniqueWorkspaceCommandId(seenIds, command.id, command.source.key, command.source.index), + }); + + if (commandEntries.length > 0) { + return commandEntries.map((entry, index) => + finalize(buildWorkspaceCommandDefinition({ + entry, + kind: entry.kind === "job" ? "job" : "service", + sourceKey: "commands", + sourceIndex: index, + serviceIndex: entry.kind === "job" ? null : nextServiceIndex++, + fallbackName: entry.kind === "job" ? `Job ${index + 1}` : `Service ${index + 1}`, + }))); + } + + const serviceDefinitions = readCommandEntries(workspaceRuntime, "services").map((entry, index) => + finalize(buildWorkspaceCommandDefinition({ + entry, + kind: "service", + sourceKey: "services", + sourceIndex: index, + serviceIndex: nextServiceIndex++, + fallbackName: `Service ${index + 1}`, + }))); + const jobDefinitions = readCommandEntries(workspaceRuntime, "jobs").map((entry, index) => + finalize(buildWorkspaceCommandDefinition({ + entry, + kind: "job", + sourceKey: "jobs", + sourceIndex: index, + serviceIndex: null, + fallbackName: `Job ${index + 1}`, + }))); + + return [...serviceDefinitions, ...jobDefinitions]; +} + +export function listWorkspaceServiceCommandDefinitions( + workspaceRuntime: Record | null | undefined, +) { + return listWorkspaceCommandDefinitions(workspaceRuntime).filter((command) => command.kind === "service"); +} + +export function findWorkspaceCommandDefinition( + workspaceRuntime: Record | null | undefined, + workspaceCommandId: string | null | undefined, +) { + const normalizedId = readNonEmptyString(workspaceCommandId); + if (!normalizedId) return null; + return listWorkspaceCommandDefinitions(workspaceRuntime).find((command) => command.id === normalizedId) ?? null; +} + +export function scoreWorkspaceRuntimeServiceMatch( + command: Pick, + runtimeService: Pick, +) { + if (command.serviceIndex !== null && runtimeService.configIndex !== null && runtimeService.configIndex !== undefined) { + return runtimeService.configIndex === command.serviceIndex ? 100 : -1; + } + + let score = 0; + if (runtimeService.serviceName === command.name) score += 4; + if ((runtimeService.command ?? null) === (command.command ?? null)) score += 4; + if ( + command.cwd + && runtimeService.cwd + && (runtimeService.cwd === command.cwd || runtimeService.cwd.endsWith(`/${command.cwd}`)) + ) { + score += 2; + } + return score; +} + +export function matchWorkspaceRuntimeServiceToCommand< + T extends Pick, +>( + command: Pick, + runtimeServices: T[] | null | undefined, +) { + let bestMatch: T | null = null; + let bestScore = -1; + + for (const runtimeService of runtimeServices ?? []) { + const score = scoreWorkspaceRuntimeServiceMatch(command, runtimeService); + if (score > bestScore) { + bestMatch = runtimeService; + bestScore = score; + } + } + + return bestScore > 0 ? bestMatch : null; +} diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 19bb57088f2..6e2287a012a 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -19,9 +19,11 @@ import { } from "@paperclipai/db"; import { eq } from "drizzle-orm"; import { + buildWorkspaceRuntimeDesiredStatePatch, cleanupExecutionWorkspaceArtifacts, ensureServerWorkspaceLinksCurrent, ensureRuntimeServicesForRun, + listConfiguredRuntimeServiceEntries, normalizeAdapterManagedRuntimeServices, reconcilePersistedRuntimeServicesOnStartup, realizeExecutionWorkspace, @@ -29,6 +31,7 @@ import { resetRuntimeServicesForTests, resolveShell, sanitizeRuntimeServiceBaseEnv, + startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForExecutionWorkspace, type RealizedExecutionWorkspace, } from "../services/workspace-runtime.ts"; @@ -2075,6 +2078,189 @@ describe("ensureRuntimeServicesForRun", () => { await releaseRuntimeServicesForRun(runId); leasedRunIds.delete(runId); }); + + it("starts only the selected workspace-controlled runtime service", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-control-start-")); + const workspace = buildWorkspace(workspaceRoot); + + const services = await startRuntimeServicesForWorkspaceControl({ + actor: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + issue: null, + workspace, + executionWorkspaceId: "execution-workspace-control-start", + config: { + workspaceRuntime: { + services: [ + { + name: "web", + command: + "node -e \"require('node:http').createServer((req,res)=>res.end('web')).listen(Number(process.env.PORT), '127.0.0.1')\"", + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + lifecycle: "shared", + reuseScope: "execution_workspace", + }, + { + name: "worker", + command: + "node -e \"require('node:http').createServer((req,res)=>res.end('worker')).listen(Number(process.env.PORT), '127.0.0.1')\"", + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + lifecycle: "shared", + reuseScope: "execution_workspace", + }, + ], + }, + }, + adapterEnv: {}, + serviceIndex: 1, + }); + + expect(services).toHaveLength(1); + expect(services[0]?.serviceName).toBe("worker"); + await expect(fetch(services[0]!.url!)).resolves.toMatchObject({ ok: true }); + + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-control-start", + workspaceCwd: workspace.cwd, + }); + }); + + it("stops only the selected execution workspace runtime service", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-control-stop-")); + const workspace = buildWorkspace(workspaceRoot); + + const services = await startRuntimeServicesForWorkspaceControl({ + actor: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + issue: null, + workspace, + executionWorkspaceId: "execution-workspace-control-stop", + config: { + workspaceRuntime: { + services: [ + { + name: "web", + command: + "node -e \"require('node:http').createServer((req,res)=>res.end('web')).listen(Number(process.env.PORT), '127.0.0.1')\"", + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + lifecycle: "shared", + reuseScope: "execution_workspace", + stopPolicy: { + type: "manual", + }, + }, + { + name: "worker", + command: + "node -e \"require('node:http').createServer((req,res)=>res.end('worker')).listen(Number(process.env.PORT), '127.0.0.1')\"", + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + lifecycle: "shared", + reuseScope: "execution_workspace", + stopPolicy: { + type: "manual", + }, + }, + ], + }, + }, + adapterEnv: {}, + }); + + expect(services).toHaveLength(2); + const web = services.find((service) => service.serviceName === "web"); + const worker = services.find((service) => service.serviceName === "worker"); + + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-control-stop", + workspaceCwd: workspace.cwd, + runtimeServiceId: web?.id ?? null, + }); + + await expect(fetch(web!.url!)).rejects.toThrow(); + await expect(fetch(worker!.url!)).resolves.toMatchObject({ ok: true }); + + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-control-stop", + workspaceCwd: workspace.cwd, + runtimeServiceId: worker?.id ?? null, + }); + }); +}); + +describe("buildWorkspaceRuntimeDesiredStatePatch", () => { + it("derives service entries from command-first runtime config", () => { + const services = listConfiguredRuntimeServiceEntries({ + workspaceRuntime: { + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev" }, + { id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" }, + ], + }, + }); + + expect(services).toEqual([ + expect.objectContaining({ + id: "web", + kind: "service", + command: "pnpm dev", + }), + ]); + }); + + it("preserves sibling service state when updating a single configured runtime service", () => { + const patch = buildWorkspaceRuntimeDesiredStatePatch({ + config: { + workspaceRuntime: { + services: [ + { name: "web", command: "pnpm dev" }, + { name: "worker", command: "pnpm worker" }, + ], + }, + }, + currentDesiredState: "running", + currentServiceStates: null, + action: "stop", + serviceIndex: 1, + }); + + expect(patch).toEqual({ + desiredState: "running", + serviceStates: { + "0": "running", + "1": "stopped", + }, + }); + }); }); describe("resolveShell (shell fallback)", () => { diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index 4fa204255a0..26e35cfa44f 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -1,15 +1,23 @@ import { and, eq } from "drizzle-orm"; -import { Router } from "express"; +import { Router, type Request, type Response } from "express"; import type { Db } from "@paperclipai/db"; import { issues, projects, projectWorkspaces } from "@paperclipai/db"; -import { updateExecutionWorkspaceSchema } from "@paperclipai/shared"; +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, + listConfiguredRuntimeServiceEntries, + runWorkspaceJobForControl, startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForExecutionWorkspace, } from "../services/workspace-runtime.js"; @@ -72,11 +80,11 @@ export function executionWorkspaceRoutes(db: Db) { res.json(operations); }); - router.post("/execution-workspaces/:id/runtime-services/:action", async (req, res) => { + 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") { - res.status(404).json({ error: "Runtime service action not found" }); + if (action !== "start" && action !== "stop" && action !== "restart" && action !== "run") { + res.status(404).json({ error: "Workspace command action not found" }); return; } @@ -89,7 +97,7 @@ export function executionWorkspaceRoutes(db: Db) { const workspaceCwd = existing.cwd; if (!workspaceCwd) { - res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can manage local runtime services" }); + res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can run workspace commands" }); return; } @@ -116,9 +124,53 @@ export function executionWorkspaceRoutes(db: Db) { (projectWorkspace?.metadata as Record | null) ?? null, )?.workspaceRuntime ?? 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 runtime service configuration or inherited project workspace default" }); + res.status(422).json({ error: "Execution workspace has no workspace command configuration or inherited project workspace default" }); return; } @@ -133,13 +185,67 @@ export function executionWorkspaceRoutes(db: Db) { const operation = await recorder.recordOperation({ phase: action === "stop" ? "workspace_teardown" : "workspace_provision", - command: `workspace runtime ${action}`, + 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 () => { + if (action === "run") { + if (!workspaceCommand || workspaceCommand.kind !== "job") { + throw new Error("Workspace job selection is required"); + } + 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: { + 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, + }, + 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.push(chunk); else stderr.push(chunk); @@ -150,6 +256,7 @@ export function executionWorkspaceRoutes(db: Db) { db, executionWorkspaceId: existing.id, workspaceCwd, + runtimeServiceId: selectedRuntimeServiceId, }); } @@ -186,14 +293,36 @@ export function executionWorkspaceRoutes(db: Db) { config: { workspaceRuntime: effectiveRuntimeConfig }, adapterEnv: {}, onLog, + serviceIndex: selectedServiceIndex, }); runtimeServiceCount = startedServices.length; } else { - runtimeServiceCount = 0; + 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: action === "stop" ? "stopped" : "running", + desiredState: nextRuntimeState.desiredState, + serviceStates: nextRuntimeState.serviceStates, }); await svc.update(existing.id, { metadata }); @@ -209,6 +338,9 @@ export function executionWorkspaceRoutes(db: Db) { : "Started execution workspace runtime services.\n", metadata: { runtimeServiceCount, + workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, + runtimeServiceId: selectedRuntimeServiceId, + serviceIndex: selectedServiceIndex, }, }; }, @@ -231,6 +363,11 @@ export function executionWorkspaceRoutes(db: Db) { entityId: existing.id, details: { runtimeServiceCount, + workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, + workspaceCommandKind: workspaceCommand?.kind ?? null, + workspaceCommandName: workspaceCommand?.name ?? null, + runtimeServiceId: selectedRuntimeServiceId, + serviceIndex: selectedServiceIndex, }, }); @@ -238,7 +375,10 @@ export function executionWorkspaceRoutes(db: Db) { 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; diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index cd523dce772..71cd792b0a6 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -1,18 +1,27 @@ -import { Router, type Request } from "express"; +import { Router, type Request, type Response } from "express"; import type { Db } from "@paperclipai/db"; import { createProjectSchema, createProjectWorkspaceSchema, + findWorkspaceCommandDefinition, isUuidLike, + matchWorkspaceRuntimeServiceToCommand, updateProjectSchema, updateProjectWorkspaceSchema, + workspaceRuntimeControlTargetSchema, } from "@paperclipai/shared"; import { trackProjectCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { projectService, logActivity, secretService, workspaceOperationService } from "../services/index.js"; import { conflict } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; -import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js"; +import { + buildWorkspaceRuntimeDesiredStatePatch, + listConfiguredRuntimeServiceEntries, + runWorkspaceJobForControl, + startRuntimeServicesForWorkspaceControl, + stopRuntimeServicesForProjectWorkspace, +} from "../services/workspace-runtime.js"; import { getTelemetryClient } from "../telemetry.js"; export function projectRoutes(db: Db) { @@ -259,12 +268,12 @@ export function projectRoutes(db: Db) { }, ); - router.post("/projects/:id/workspaces/:workspaceId/runtime-services/:action", async (req, res) => { + async function handleProjectWorkspaceRuntimeCommand(req: Request, res: Response) { 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" }); + if (action !== "start" && action !== "stop" && action !== "restart" && action !== "run") { + res.status(404).json({ error: "Workspace command action not found" }); return; } @@ -283,13 +292,55 @@ export function projectRoutes(db: Db) { const workspaceCwd = workspace.cwd; if (!workspaceCwd) { - res.status(422).json({ error: "Project workspace needs a local path before Paperclip can manage local runtime services" }); + res.status(422).json({ error: "Project workspace needs a local path before Paperclip can run workspace commands" }); return; } const runtimeConfig = workspace.runtimeConfig?.workspaceRuntime ?? null; + const target = req.body as { workspaceCommandId?: string | null; runtimeServiceId?: string | null; serviceIndex?: number | null }; + const configuredServices = runtimeConfig ? listConfiguredRuntimeServiceEntries({ workspaceRuntime: runtimeConfig }) : []; + const workspaceCommand = runtimeConfig + ? findWorkspaceCommandDefinition(runtimeConfig, target.workspaceCommandId ?? null) + : null; + if (target.workspaceCommandId && !workspaceCommand) { + res.status(404).json({ error: "Workspace command not found for this project workspace" }); + return; + } + if (target.runtimeServiceId && !(workspace.runtimeServices ?? []).some((service) => service.id === target.runtimeServiceId)) { + res.status(404).json({ error: "Runtime service not found for this project workspace" }); + return; + } + const matchedRuntimeService = + workspaceCommand?.kind === "service" && !target.runtimeServiceId + ? matchWorkspaceRuntimeServiceToCommand(workspaceCommand, workspace.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 project 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") && !runtimeConfig) { - res.status(422).json({ error: "Project workspace has no runtime service configuration" }); + res.status(422).json({ error: "Project workspace has no workspace command configuration" }); return; } @@ -301,14 +352,63 @@ export function projectRoutes(db: Db) { const operation = await recorder.recordOperation({ phase: action === "stop" ? "workspace_teardown" : "workspace_provision", - command: `workspace runtime ${action}`, + command: workspaceCommand?.command ?? `workspace command ${action}`, cwd: workspace.cwd, metadata: { action, projectId: project.id, projectWorkspaceId: workspace.id, + workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, + workspaceCommandKind: workspaceCommand?.kind ?? null, + workspaceCommandName: workspaceCommand?.name ?? null, + runtimeServiceId: selectedRuntimeServiceId, + serviceIndex: selectedServiceIndex, }, run: async () => { + if (action === "run") { + if (!workspaceCommand || workspaceCommand.kind !== "job") { + throw new Error("Workspace job selection is required"); + } + return await runWorkspaceJobForControl({ + 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, + }, + command: workspaceCommand.rawConfig, + adapterEnv: {}, + recorder, + metadata: { + action, + projectId: project.id, + projectWorkspaceId: workspace.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.push(chunk); else stderr.push(chunk); @@ -318,6 +418,7 @@ export function projectRoutes(db: Db) { await stopRuntimeServicesForProjectWorkspace({ db, projectWorkspaceId: workspace.id, + runtimeServiceId: selectedRuntimeServiceId, }); } @@ -347,15 +448,37 @@ export function projectRoutes(db: Db) { config: { workspaceRuntime: runtimeConfig }, adapterEnv: {}, onLog, + serviceIndex: selectedServiceIndex, }); runtimeServiceCount = startedServices.length; } else { - runtimeServiceCount = 0; + runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (workspace.runtimeServices?.length ?? 1) - 1) : 0; } + const currentDesiredState: "running" | "stopped" = + workspace.runtimeConfig?.desiredState + ?? ((workspace.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: workspace.runtimeConfig?.serviceStates ?? null, + } + : buildWorkspaceRuntimeDesiredStatePatch({ + config: { workspaceRuntime: runtimeConfig }, + currentDesiredState, + currentServiceStates: workspace.runtimeConfig?.serviceStates ?? null, + action, + serviceIndex: selectedServiceIndex, + }); await svc.updateWorkspace(project.id, workspace.id, { runtimeConfig: { - desiredState: action === "stop" ? "stopped" : "running", + desiredState: nextRuntimeState.desiredState, + serviceStates: nextRuntimeState.serviceStates, }, }); @@ -371,6 +494,9 @@ export function projectRoutes(db: Db) { : "Started project workspace runtime services.\n", metadata: { runtimeServiceCount, + workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, + runtimeServiceId: selectedRuntimeServiceId, + serviceIndex: selectedServiceIndex, }, }; }, @@ -389,6 +515,11 @@ export function projectRoutes(db: Db) { details: { projectWorkspaceId: workspace.id, runtimeServiceCount, + workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, + workspaceCommandKind: workspaceCommand?.kind ?? null, + workspaceCommandName: workspaceCommand?.name ?? null, + runtimeServiceId: selectedRuntimeServiceId, + serviceIndex: selectedServiceIndex, }, }); @@ -396,7 +527,10 @@ export function projectRoutes(db: Db) { workspace: updatedWorkspace, operation, }); - }); + } + + router.post("/projects/:id/workspaces/:workspaceId/runtime-services/:action", validate(workspaceRuntimeControlTargetSchema), handleProjectWorkspaceRuntimeCommand); + router.post("/projects/:id/workspaces/:workspaceId/runtime-commands/:action", validate(workspaceRuntimeControlTargetSchema), handleProjectWorkspaceRuntimeCommand); router.delete("/projects/:id/workspaces/:workspaceId", async (req, res) => { const id = req.params.id as string; diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 58a43210f2a..af1c3ee81a5 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -192,6 +192,11 @@ export function readExecutionWorkspaceConfig(metadata: Record | cleanupCommand: readNullableString(raw.cleanupCommand), workspaceRuntime: cloneRecord(raw.workspaceRuntime), desiredState: raw.desiredState === "running" || raw.desiredState === "stopped" ? raw.desiredState : null, + serviceStates: isRecord(raw.serviceStates) + ? Object.fromEntries( + Object.entries(raw.serviceStates).filter(([, state]) => state === "running" || state === "stopped"), + ) as ExecutionWorkspaceConfig["serviceStates"] + : null, }; const hasConfig = Object.values(config).some((value) => { @@ -214,6 +219,7 @@ export function mergeExecutionWorkspaceConfig( cleanupCommand: null, workspaceRuntime: null, desiredState: null, + serviceStates: null, }; if (patch === null) { @@ -232,6 +238,14 @@ export function mergeExecutionWorkspaceConfig( ? patch.desiredState : null : current.desiredState, + serviceStates: + patch.serviceStates !== undefined && isRecord(patch.serviceStates) + ? Object.fromEntries( + Object.entries(patch.serviceStates).filter(([, state]) => state === "running" || state === "stopped"), + ) as ExecutionWorkspaceConfig["serviceStates"] + : patch.serviceStates !== undefined + ? null + : current.serviceStates, }; const hasConfig = Object.values(nextConfig).some((value) => { @@ -247,6 +261,7 @@ export function mergeExecutionWorkspaceConfig( cleanupCommand: nextConfig.cleanupCommand, workspaceRuntime: nextConfig.workspaceRuntime, desiredState: nextConfig.desiredState, + serviceStates: nextConfig.serviceStates ?? null, }; } else { delete nextMetadata.config; diff --git a/server/src/services/project-workspace-runtime-config.ts b/server/src/services/project-workspace-runtime-config.ts index 8252fecd31d..9a36c957958 100644 --- a/server/src/services/project-workspace-runtime-config.ts +++ b/server/src/services/project-workspace-runtime-config.ts @@ -12,6 +12,13 @@ function readDesiredState(value: unknown): ProjectWorkspaceRuntimeConfig["desire return value === "running" || value === "stopped" ? value : null; } +function readServiceStates(value: unknown): ProjectWorkspaceRuntimeConfig["serviceStates"] { + if (!isRecord(value)) return null; + const entries = Object.entries(value).filter(([, state]) => state === "running" || state === "stopped"); + if (entries.length === 0) return null; + return Object.fromEntries(entries) as ProjectWorkspaceRuntimeConfig["serviceStates"]; +} + export function readProjectWorkspaceRuntimeConfig( metadata: Record | null | undefined, ): ProjectWorkspaceRuntimeConfig | null { @@ -21,9 +28,10 @@ export function readProjectWorkspaceRuntimeConfig( const config: ProjectWorkspaceRuntimeConfig = { workspaceRuntime: cloneRecord(raw.workspaceRuntime), desiredState: readDesiredState(raw.desiredState), + serviceStates: readServiceStates(raw.serviceStates), }; - const hasConfig = config.workspaceRuntime !== null || config.desiredState !== null; + const hasConfig = config.workspaceRuntime !== null || config.desiredState !== null || config.serviceStates !== null; return hasConfig ? config : null; } @@ -35,6 +43,7 @@ export function mergeProjectWorkspaceRuntimeConfig( const current = readProjectWorkspaceRuntimeConfig(metadata) ?? { workspaceRuntime: null, desiredState: null, + serviceStates: null, }; if (patch === null) { @@ -47,9 +56,11 @@ export function mergeProjectWorkspaceRuntimeConfig( patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime, desiredState: patch.desiredState !== undefined ? readDesiredState(patch.desiredState) : current.desiredState, + serviceStates: + patch.serviceStates !== undefined ? readServiceStates(patch.serviceStates) : current.serviceStates, }; - if (nextConfig.workspaceRuntime === null && nextConfig.desiredState === null) { + if (nextConfig.workspaceRuntime === null && nextConfig.desiredState === null && nextConfig.serviceStates === null) { delete nextMetadata.runtimeConfig; } else { nextMetadata.runtimeConfig = nextConfig; diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index bcfb4908a65..b7dac8d69b6 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -8,6 +8,11 @@ import { setTimeout as delay } from "node:timers/promises"; import type { AdapterRuntimeServiceReport } from "@paperclipai/adapter-utils"; import type { Db } from "@paperclipai/db"; import { executionWorkspaces, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; +import { + listWorkspaceServiceCommandDefinitions, + type WorkspaceRuntimeDesiredState, + type WorkspaceRuntimeServiceStateMap, +} from "@paperclipai/shared"; import { and, desc, eq, inArray } from "drizzle-orm"; import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js"; import { resolveHomeAwarePath } from "../home-paths.js"; @@ -831,13 +836,13 @@ async function recordWorkspaceCommandOperation( ) { if (!recorder) { await runWorkspaceCommand(input); - return; + return null; } let stdout = ""; let stderr = ""; let code: number | null = null; - await recorder.recordOperation({ + const operation = await recorder.recordOperation({ phase: input.phase, command: input.command, cwd: input.cwd, @@ -872,7 +877,7 @@ async function recordWorkspaceCommandOperation( }, }); - if (code === 0) return; + if (code === 0) return operation; const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n"); throw new Error( @@ -1448,6 +1453,83 @@ function resolveRuntimeServiceReuseIdentity(input: { }; } +function resolveWorkspaceCommandExecution(input: { + command: Record; + workspace: RealizedExecutionWorkspace; + agent: ExecutionWorkspaceAgentRef; + issue: ExecutionWorkspaceIssueRef | null; + adapterEnv: Record; +}) { + const name = + asString(input.command.name, "") + || asString(input.command.label, "") + || asString(input.command.title, "") + || "workspace command"; + const command = asString(input.command.command, ""); + const templateData = buildTemplateData({ + workspace: input.workspace, + agent: input.agent, + issue: input.issue, + adapterEnv: input.adapterEnv, + port: null, + }); + const cwd = resolveConfiguredPath( + renderTemplate(asString(input.command.cwd, "."), templateData), + input.workspace.cwd, + ); + const env = { + ...sanitizeRuntimeServiceBaseEnv(process.env), + ...input.adapterEnv, + ...renderRuntimeServiceEnv({ + envConfig: parseObject(input.command.env), + templateData, + }), + } as Record; + + return { + name, + command, + cwd, + env, + }; +} + +export async function runWorkspaceJobForControl(input: { + actor: ExecutionWorkspaceAgentRef; + issue: ExecutionWorkspaceIssueRef | null; + workspace: RealizedExecutionWorkspace; + command: Record; + adapterEnv?: Record; + recorder?: WorkspaceOperationRecorder | null; + metadata?: Record | null; +}) { + const resolved = resolveWorkspaceCommandExecution({ + command: input.command, + workspace: input.workspace, + agent: input.actor, + issue: input.issue, + adapterEnv: input.adapterEnv ?? {}, + }); + if (!resolved.command) { + throw new Error(`Workspace job "${resolved.name}" is missing command`); + } + + await ensureServerWorkspaceLinksCurrent(resolved.cwd); + return await recordWorkspaceCommandOperation(input.recorder, { + phase: "workspace_provision", + command: resolved.command, + cwd: resolved.cwd, + env: resolved.env, + label: `Workspace job "${resolved.name}"`, + metadata: { + workspaceCommandKind: "job", + workspaceCommandName: resolved.name, + ...(input.metadata ?? {}), + }, + successMessage: `Completed workspace job "${resolved.name}"\n`, + }); +} + function resolveServiceScopeId(input: { service: Record; workspace: RealizedExecutionWorkspace; @@ -1989,10 +2071,78 @@ 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) - : []; + return listWorkspaceServiceCommandDefinitions(parseObject(config.workspaceRuntime)) + .map((command) => command.rawConfig); +} + +export function listConfiguredRuntimeServiceEntries(config: Record) { + return readRuntimeServiceEntries(config); +} + +function readConfiguredServiceStates(config: Record) { + const raw = parseObject(config.serviceStates); + const states: WorkspaceRuntimeServiceStateMap = {}; + for (const [key, value] of Object.entries(raw)) { + if (value === "running" || value === "stopped") { + states[key] = value; + } + } + return states; +} + +export function buildWorkspaceRuntimeDesiredStatePatch(input: { + config: Record; + currentDesiredState: WorkspaceRuntimeDesiredState | null; + currentServiceStates: WorkspaceRuntimeServiceStateMap | null | undefined; + action: "start" | "stop" | "restart"; + serviceIndex?: number | null; +}): { + desiredState: WorkspaceRuntimeDesiredState; + serviceStates: WorkspaceRuntimeServiceStateMap | null; +} { + const configuredServices = listConfiguredRuntimeServiceEntries(input.config); + const fallbackState: WorkspaceRuntimeDesiredState = input.currentDesiredState === "running" ? "running" : "stopped"; + const nextServiceStates: WorkspaceRuntimeServiceStateMap = {}; + + for (let index = 0; index < configuredServices.length; index += 1) { + nextServiceStates[String(index)] = input.currentServiceStates?.[String(index)] ?? fallbackState; + } + + const nextState: WorkspaceRuntimeDesiredState = input.action === "stop" ? "stopped" : "running"; + if (input.serviceIndex === undefined || input.serviceIndex === null) { + for (let index = 0; index < configuredServices.length; index += 1) { + nextServiceStates[String(index)] = nextState; + } + } else if (input.serviceIndex >= 0 && input.serviceIndex < configuredServices.length) { + nextServiceStates[String(input.serviceIndex)] = nextState; + } + + const desiredState = Object.values(nextServiceStates).some((state) => state === "running") ? "running" : "stopped"; + + return { + desiredState, + serviceStates: Object.keys(nextServiceStates).length > 0 ? nextServiceStates : null, + }; +} + +function selectRuntimeServiceEntries(input: { + config: Record; + serviceIndex?: number | null; + respectDesiredStates?: boolean; + defaultDesiredState?: WorkspaceRuntimeDesiredState | null; + serviceStates?: WorkspaceRuntimeServiceStateMap | null; +}) { + const entries = listConfiguredRuntimeServiceEntries(input.config); + const states = input.serviceStates ?? readConfiguredServiceStates(input.config); + const fallbackState: WorkspaceRuntimeDesiredState = input.defaultDesiredState === "running" ? "running" : "stopped"; + + return entries.filter((_, index) => { + if (input.serviceIndex !== undefined && input.serviceIndex !== null) { + return index === input.serviceIndex; + } + if (!input.respectDesiredStates) return true; + return (states[String(index)] ?? fallbackState) === "running"; + }); } export async function ensureRuntimeServicesForRun(input: { @@ -2087,8 +2237,16 @@ export async function startRuntimeServicesForWorkspaceControl(input: { config: Record; adapterEnv: Record; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + serviceIndex?: number | null; + respectDesiredStates?: boolean; }): Promise { - const rawServices = readRuntimeServiceEntries(input.config); + const rawServices = selectRuntimeServiceEntries({ + config: input.config, + serviceIndex: input.serviceIndex, + respectDesiredStates: input.respectDesiredStates, + defaultDesiredState: input.config.desiredState === "running" ? "running" : "stopped", + serviceStates: readConfiguredServiceStates(input.config), + }); const refs: RuntimeServiceRef[] = []; const invocationId = input.invocationId ?? randomUUID(); @@ -2178,10 +2336,12 @@ export async function stopRuntimeServicesForExecutionWorkspace(input: { db?: Db; executionWorkspaceId: string; workspaceCwd?: string | null; + runtimeServiceId?: string | null; }) { const normalizedWorkspaceCwd = input.workspaceCwd ? path.resolve(input.workspaceCwd) : null; const matchingServiceIds = Array.from(runtimeServicesById.values()) .filter((record) => { + if (input.runtimeServiceId) return record.id === input.runtimeServiceId; if (record.executionWorkspaceId === input.executionWorkspaceId) return true; if (!normalizedWorkspaceCwd || !record.cwd) return false; const resolvedCwd = path.resolve(record.cwd); @@ -2197,19 +2357,37 @@ export async function stopRuntimeServicesForExecutionWorkspace(input: { } if (input.db) { - await markPersistedRuntimeServicesStoppedForExecutionWorkspace({ - db: input.db, - executionWorkspaceId: input.executionWorkspaceId, - }); + if (input.runtimeServiceId) { + const now = new Date(); + await input.db + .update(workspaceRuntimeServices) + .set({ + status: "stopped", + healthStatus: "unknown", + stoppedAt: now, + lastUsedAt: now, + updatedAt: now, + }) + .where(eq(workspaceRuntimeServices.id, input.runtimeServiceId)); + } else { + await markPersistedRuntimeServicesStoppedForExecutionWorkspace({ + db: input.db, + executionWorkspaceId: input.executionWorkspaceId, + }); + } } } export async function stopRuntimeServicesForProjectWorkspace(input: { db?: Db; projectWorkspaceId: string; + runtimeServiceId?: string | null; }) { const matchingServiceIds = Array.from(runtimeServicesById.values()) - .filter((record) => record.projectWorkspaceId === input.projectWorkspaceId && record.scopeType === "project_workspace") + .filter((record) => { + if (input.runtimeServiceId) return record.id === input.runtimeServiceId; + return record.projectWorkspaceId === input.projectWorkspaceId && record.scopeType === "project_workspace"; + }) .map((record) => record.id); for (const serviceId of matchingServiceIds) { @@ -2228,11 +2406,13 @@ export async function stopRuntimeServicesForProjectWorkspace(input: { updatedAt: now, }) .where( - and( - eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId), - eq(workspaceRuntimeServices.scopeType, "project_workspace"), - inArray(workspaceRuntimeServices.status, ["starting", "running"]), - ), + input.runtimeServiceId + ? eq(workspaceRuntimeServices.id, input.runtimeServiceId) + : and( + eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId), + eq(workspaceRuntimeServices.scopeType, "project_workspace"), + inArray(workspaceRuntimeServices.status, ["starting", "running"]), + ), ); } } @@ -2368,6 +2548,7 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) { const projectWorkspaceRows = await db .select() .from(projectWorkspaces); + const projectWorkspaceRowsById = new Map(projectWorkspaceRows.map((row) => [row.id, row] as const)); for (const row of projectWorkspaceRows) { const runtimeConfig = readProjectWorkspaceRuntimeConfig((row.metadata as Record | null) ?? null); @@ -2392,8 +2573,13 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) { warnings: [], created: false, }, - config: { workspaceRuntime: runtimeConfig.workspaceRuntime }, + config: { + workspaceRuntime: runtimeConfig.workspaceRuntime, + desiredState: runtimeConfig.desiredState, + serviceStates: runtimeConfig.serviceStates ?? null, + }, adapterEnv: {}, + respectDesiredStates: true, }); if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length; } catch { @@ -2408,7 +2594,13 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) { for (const row of executionWorkspaceRows) { const config = readExecutionWorkspaceConfig((row.metadata as Record | null) ?? null); - if (config?.desiredState !== "running" || !config.workspaceRuntime || !row.cwd) continue; + const inheritedRuntimeConfig = row.projectWorkspaceId + ? readProjectWorkspaceRuntimeConfig( + (projectWorkspaceRowsById.get(row.projectWorkspaceId)?.metadata as Record | null) ?? null, + )?.workspaceRuntime ?? null + : null; + const effectiveRuntimeConfig = config?.workspaceRuntime ?? inheritedRuntimeConfig; + if (config?.desiredState !== "running" || !effectiveRuntimeConfig || !row.cwd) continue; try { const refs = await startRuntimeServicesForWorkspaceControl({ @@ -2436,8 +2628,13 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) { created: false, }, executionWorkspaceId: row.id, - config: { workspaceRuntime: config.workspaceRuntime }, + config: { + workspaceRuntime: effectiveRuntimeConfig, + desiredState: config.desiredState, + serviceStates: config.serviceStates ?? null, + }, adapterEnv: {}, + respectDesiredStates: true, }); if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length; } catch { diff --git a/ui/src/api/execution-workspaces.ts b/ui/src/api/execution-workspaces.ts index 3644af77feb..7318d8eb90e 100644 --- a/ui/src/api/execution-workspaces.ts +++ b/ui/src/api/execution-workspaces.ts @@ -1,4 +1,9 @@ -import type { ExecutionWorkspace, ExecutionWorkspaceCloseReadiness, WorkspaceOperation } from "@paperclipai/shared"; +import type { + ExecutionWorkspace, + ExecutionWorkspaceCloseReadiness, + WorkspaceOperation, + WorkspaceRuntimeControlTarget, +} from "@paperclipai/shared"; import { api } from "./client"; export const executionWorkspacesApi = { @@ -26,10 +31,23 @@ export const executionWorkspacesApi = { api.get(`/execution-workspaces/${id}/close-readiness`), listWorkspaceOperations: (id: string) => api.get(`/execution-workspaces/${id}/workspace-operations`), - controlRuntimeServices: (id: string, action: "start" | "stop" | "restart") => + controlRuntimeServices: ( + id: string, + action: "start" | "stop" | "restart", + target: WorkspaceRuntimeControlTarget = {}, + ) => api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>( `/execution-workspaces/${id}/runtime-services/${action}`, - {}, + target, + ), + controlRuntimeCommands: ( + id: string, + action: "start" | "stop" | "restart" | "run", + target: WorkspaceRuntimeControlTarget = {}, + ) => + api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>( + `/execution-workspaces/${id}/runtime-commands/${action}`, + target, ), 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 763718ffb51..fb40c9e144b 100644 --- a/ui/src/api/projects.ts +++ b/ui/src/api/projects.ts @@ -1,4 +1,9 @@ -import type { Project, ProjectWorkspace, WorkspaceOperation } from "@paperclipai/shared"; +import type { + Project, + ProjectWorkspace, + WorkspaceOperation, + WorkspaceRuntimeControlTarget, +} from "@paperclipai/shared"; import { api } from "./client"; function withCompanyScope(path: string, companyId?: string) { @@ -32,10 +37,22 @@ export const projectsApi = { workspaceId: string, action: "start" | "stop" | "restart", companyId?: string, + target: WorkspaceRuntimeControlTarget = {}, ) => api.post<{ workspace: ProjectWorkspace; operation: WorkspaceOperation }>( projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}/runtime-services/${action}`), - {}, + target, + ), + controlWorkspaceCommands: ( + projectId: string, + workspaceId: string, + action: "start" | "stop" | "restart" | "run", + companyId?: string, + target: WorkspaceRuntimeControlTarget = {}, + ) => + api.post<{ workspace: ProjectWorkspace; operation: WorkspaceOperation }>( + projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}/runtime-commands/${action}`), + target, ), removeWorkspace: (projectId: string, workspaceId: string, companyId?: string) => api.delete(projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`)), diff --git a/ui/src/components/WorkspaceRuntimeControls.test.tsx b/ui/src/components/WorkspaceRuntimeControls.test.tsx index c0db951642a..af160628ae2 100644 --- a/ui/src/components/WorkspaceRuntimeControls.test.tsx +++ b/ui/src/components/WorkspaceRuntimeControls.test.tsx @@ -2,12 +2,81 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; +import type { WorkspaceRuntimeService } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { WorkspaceRuntimeControls } from "./WorkspaceRuntimeControls"; +import { + buildWorkspaceRuntimeControlSections, + WorkspaceRuntimeControls, +} from "./WorkspaceRuntimeControls"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +function createRuntimeService(overrides: Partial = {}): WorkspaceRuntimeService { + return { + id: overrides.id ?? "service-1", + companyId: overrides.companyId ?? "company-1", + projectId: overrides.projectId ?? "project-1", + projectWorkspaceId: overrides.projectWorkspaceId ?? "workspace-1", + executionWorkspaceId: overrides.executionWorkspaceId ?? null, + issueId: overrides.issueId ?? null, + scopeType: overrides.scopeType ?? "project_workspace", + scopeId: overrides.scopeId ?? "workspace-1", + serviceName: overrides.serviceName ?? "web", + status: overrides.status ?? "stopped", + lifecycle: overrides.lifecycle ?? "shared", + reuseKey: overrides.reuseKey ?? null, + command: overrides.command ?? "pnpm dev", + cwd: overrides.cwd ?? "/repo", + port: overrides.port ?? null, + url: overrides.url ?? null, + provider: overrides.provider ?? "local_process", + providerRef: overrides.providerRef ?? null, + ownerAgentId: overrides.ownerAgentId ?? null, + startedByRunId: overrides.startedByRunId ?? null, + lastUsedAt: overrides.lastUsedAt ?? new Date("2026-04-12T00:00:00.000Z"), + startedAt: overrides.startedAt ?? new Date("2026-04-12T00:00:00.000Z"), + stoppedAt: overrides.stoppedAt ?? null, + stopPolicy: overrides.stopPolicy ?? null, + healthStatus: overrides.healthStatus ?? "unknown", + configIndex: overrides.configIndex ?? null, + createdAt: overrides.createdAt ?? new Date("2026-04-12T00:00:00.000Z"), + updatedAt: overrides.updatedAt ?? new Date("2026-04-12T00:00:00.000Z"), + }; +} + +describe("buildWorkspaceRuntimeControlSections", () => { + it("separates service and job commands while matching running services", () => { + const sections = buildWorkspaceRuntimeControlSections({ + runtimeConfig: { + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev" }, + { id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" }, + ], + }, + runtimeServices: [ + createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }), + ], + canStartServices: true, + canRunJobs: true, + }); + + expect(sections.services).toHaveLength(1); + expect(sections.jobs).toHaveLength(1); + expect(sections.services[0]).toMatchObject({ + title: "web", + statusLabel: "running", + workspaceCommandId: "web", + runtimeServiceId: "service-web", + }); + expect(sections.jobs[0]).toMatchObject({ + title: "db:migrate", + statusLabel: "run once", + workspaceCommandId: "db-migrate", + }); + }); +}); + describe("WorkspaceRuntimeControls", () => { let container: HTMLDivElement; @@ -20,45 +89,67 @@ describe("WorkspaceRuntimeControls", () => { document.body.innerHTML = ""; }); - it("only shows start when services are not running", () => { - const root = createRoot(container); - - act(() => { - root.render(); + it("renders service and job actions distinctly", () => { + const sections = buildWorkspaceRuntimeControlSections({ + runtimeConfig: { + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev" }, + { id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" }, + ], + }, + runtimeServices: [ + createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }), + ], + canStartServices: true, + canRunJobs: true, }); - const buttons = Array.from(container.querySelectorAll("button")).map((button) => button.textContent?.trim()); - expect(buttons).toEqual(["Start"]); - }); - - it("shows stop and restart when services are already running", () => { const root = createRoot(container); - - act(() => { - root.render(); - }); - - const buttons = Array.from(container.querySelectorAll("button")).map((button) => button.textContent?.trim()); - expect(buttons).toEqual(["Stop", "Restart"]); - }); - - it("keeps start visible but disabled when runtime prerequisites are missing", () => { - const root = createRoot(container); - act(() => { root.render( , ); }); - const startButton = container.querySelector("button"); - expect(startButton?.textContent?.trim()).toBe("Start"); - expect(startButton?.hasAttribute("disabled")).toBe(true); - expect(container.textContent).toContain("Add runtime settings first."); + const buttons = Array.from(container.querySelectorAll("button")).map((button) => button.textContent?.trim()); + expect(buttons).toEqual(["Stop", "Restart", "Run"]); + expect(container.textContent).toContain("Services"); + expect(container.textContent).toContain("Jobs"); + + act(() => root.unmount()); + }); + + it("shows disabled actions when local command prerequisites are missing", () => { + const sections = buildWorkspaceRuntimeControlSections({ + runtimeConfig: { + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev" }, + { id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" }, + ], + }, + runtimeServices: [], + canStartServices: false, + canRunJobs: false, + }); + + const root = createRoot(container); + act(() => { + root.render( + , + ); + }); + + const buttons = Array.from(container.querySelectorAll("button")); + expect(buttons.every((button) => button.hasAttribute("disabled"))).toBe(true); + expect(container.textContent).toContain("Add a workspace path first."); + + act(() => root.unmount()); }); }); diff --git a/ui/src/components/WorkspaceRuntimeControls.tsx b/ui/src/components/WorkspaceRuntimeControls.tsx index 0d845ea47a9..d4afe9a4e43 100644 --- a/ui/src/components/WorkspaceRuntimeControls.tsx +++ b/ui/src/components/WorkspaceRuntimeControls.tsx @@ -1,16 +1,72 @@ -import { Activity, Loader2, Play, RotateCcw, Square } from "lucide-react"; +import type { + WorkspaceCommandDefinition, + WorkspaceRuntimeControlTarget, + WorkspaceRuntimeService, +} from "@paperclipai/shared"; +import { + listWorkspaceCommandDefinitions, + matchWorkspaceRuntimeServiceToCommand, +} from "@paperclipai/shared"; +import { Activity, ExternalLink, Loader2, Play, RotateCcw, Square } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -export type WorkspaceRuntimeAction = "start" | "stop" | "restart"; +export type WorkspaceRuntimeAction = "start" | "stop" | "restart" | "run"; + +export type WorkspaceRuntimeControlRequest = WorkspaceRuntimeControlTarget & { + action: WorkspaceRuntimeAction; +}; + +export type WorkspaceRuntimeControlItem = { + key: string; + title: string; + kind: "service" | "job"; + statusLabel: string; + lifecycle: "shared" | "ephemeral" | null; + healthStatus: "unknown" | "healthy" | "unhealthy" | null; + command: string | null; + cwd: string | null; + port: number | null; + url: string | null; + canStart: boolean; + canRun: boolean; + workspaceCommandId?: string | null; + runtimeServiceId?: string | null; + serviceIndex?: number | null; + disabledReason?: string | null; +}; + +export type WorkspaceRuntimeControlSections = { + services: WorkspaceRuntimeControlItem[]; + jobs: WorkspaceRuntimeControlItem[]; + otherServices: WorkspaceRuntimeControlItem[]; +}; + +type LegacyWorkspaceRuntimeControlItem = WorkspaceRuntimeControlItem & { + status?: string | null; +}; type WorkspaceRuntimeControlsProps = { - isRunning: boolean; - canStart: boolean; + sections: WorkspaceRuntimeControlSections; + items?: never; isPending?: boolean; - pendingAction?: WorkspaceRuntimeAction | null; + pendingRequest?: WorkspaceRuntimeControlRequest | null; + serviceEmptyMessage?: string; + jobEmptyMessage?: string; + emptyMessage?: never; disabledHint?: string | null; - onAction: (action: WorkspaceRuntimeAction) => void; + onAction: (request: WorkspaceRuntimeControlRequest) => void; + className?: string; +} | { + sections?: never; + items: LegacyWorkspaceRuntimeControlItem[]; + isPending?: boolean; + pendingRequest?: WorkspaceRuntimeControlRequest | null; + serviceEmptyMessage?: never; + jobEmptyMessage?: never; + emptyMessage?: string; + disabledHint?: string | null; + onAction: (request: WorkspaceRuntimeControlRequest) => void; className?: string; }; @@ -20,71 +76,356 @@ export function hasRunningRuntimeServices( return (runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running"); } +function buildServiceItem( + command: WorkspaceCommandDefinition, + runtimeService: WorkspaceRuntimeService | null, + canStartServices: boolean, +): WorkspaceRuntimeControlItem { + return { + key: `command:${command.id}:${runtimeService?.id ?? "idle"}`, + title: command.name, + kind: "service", + statusLabel: runtimeService?.status ?? "stopped", + lifecycle: runtimeService?.lifecycle ?? command.lifecycle, + healthStatus: runtimeService?.healthStatus ?? "unknown", + command: runtimeService?.command ?? command.command, + cwd: runtimeService?.cwd ?? command.cwd, + port: runtimeService?.port ?? null, + url: runtimeService?.url ?? null, + canStart: canStartServices && !command.disabledReason, + canRun: false, + workspaceCommandId: command.id, + runtimeServiceId: runtimeService?.id ?? null, + serviceIndex: command.serviceIndex, + disabledReason: command.disabledReason, + }; +} + +function buildJobItem( + command: WorkspaceCommandDefinition, + canRunJobs: boolean, +): WorkspaceRuntimeControlItem { + return { + key: `command:${command.id}`, + title: command.name, + kind: "job", + statusLabel: "run once", + lifecycle: null, + healthStatus: null, + command: command.command, + cwd: command.cwd, + port: null, + url: null, + canStart: false, + canRun: canRunJobs && !command.disabledReason && Boolean(command.command), + workspaceCommandId: command.id, + runtimeServiceId: null, + serviceIndex: null, + disabledReason: command.disabledReason ?? (!command.command ? "This job is missing a command." : null), + }; +} + +export function buildWorkspaceRuntimeControlSections(input: { + runtimeConfig: Record | null | undefined; + runtimeServices: WorkspaceRuntimeService[] | null | undefined; + canStartServices: boolean; + canRunJobs?: boolean; +}): WorkspaceRuntimeControlSections { + const commands = listWorkspaceCommandDefinitions(input.runtimeConfig); + const runtimeServices = [...(input.runtimeServices ?? [])]; + const matchedRuntimeServiceIds = new Set(); + const services: WorkspaceRuntimeControlItem[] = []; + const jobs: WorkspaceRuntimeControlItem[] = []; + + for (const command of commands) { + if (command.kind === "job") { + jobs.push(buildJobItem(command, input.canRunJobs ?? input.canStartServices)); + continue; + } + + const runtimeService = matchWorkspaceRuntimeServiceToCommand(command, runtimeServices); + if (runtimeService) matchedRuntimeServiceIds.add(runtimeService.id); + services.push(buildServiceItem(command, runtimeService, input.canStartServices)); + } + + const otherServices = runtimeServices + .filter((runtimeService) => !matchedRuntimeServiceIds.has(runtimeService.id)) + .map((runtimeService) => ({ + key: `runtime:${runtimeService.id}`, + title: runtimeService.serviceName, + kind: "service" as const, + statusLabel: runtimeService.status, + lifecycle: runtimeService.lifecycle, + healthStatus: runtimeService.healthStatus, + command: runtimeService.command ?? null, + cwd: runtimeService.cwd ?? null, + port: runtimeService.port ?? null, + url: runtimeService.url ?? null, + canStart: false, + canRun: false, + workspaceCommandId: null, + runtimeServiceId: runtimeService.id, + serviceIndex: runtimeService.configIndex ?? null, + disabledReason: "This runtime service no longer matches a configured workspace command.", + })); + + return { + services, + jobs, + otherServices, + }; +} + +export function buildWorkspaceRuntimeControlItems(input: { + runtimeConfig: Record | null | undefined; + runtimeServices: WorkspaceRuntimeService[] | null | undefined; + canStartServices: boolean; + canRunJobs?: boolean; +}): LegacyWorkspaceRuntimeControlItem[] { + return buildWorkspaceRuntimeControlSections(input).services.map((item) => ({ + ...item, + status: item.statusLabel, + })); +} + +function requestMatchesPending( + pendingRequest: WorkspaceRuntimeControlRequest | null | undefined, + nextRequest: WorkspaceRuntimeControlRequest, +) { + return pendingRequest?.action === nextRequest.action + && (pendingRequest?.workspaceCommandId ?? null) === (nextRequest.workspaceCommandId ?? null) + && (pendingRequest?.runtimeServiceId ?? null) === (nextRequest.runtimeServiceId ?? null) + && (pendingRequest?.serviceIndex ?? null) === (nextRequest.serviceIndex ?? null); +} + +function buildRequest(item: WorkspaceRuntimeControlItem, action: WorkspaceRuntimeAction): WorkspaceRuntimeControlRequest { + return { + action, + workspaceCommandId: item.workspaceCommandId ?? null, + runtimeServiceId: item.runtimeServiceId ?? null, + serviceIndex: item.serviceIndex ?? null, + }; +} + +function CommandActionButtons({ + item, + isPending, + pendingRequest, + onAction, +}: { + item: WorkspaceRuntimeControlItem; + isPending: boolean; + pendingRequest: WorkspaceRuntimeControlRequest | null | undefined; + onAction: (request: WorkspaceRuntimeControlRequest) => void; +}) { + const actions: WorkspaceRuntimeAction[] = + item.kind === "job" + ? ["run"] + : item.statusLabel === "running" || item.statusLabel === "starting" + ? ["stop", ...(item.canStart ? ["restart" as const] : [])] + : ["start"]; + + return ( +
+ {actions.map((action) => { + const request = buildRequest(item, action); + const Icon = action === "stop" ? Square : action === "restart" ? RotateCcw : Play; + const label = action === "run" + ? "Run" + : action === "start" + ? "Start" + : action === "stop" + ? "Stop" + : "Restart"; + const showSpinner = isPending && requestMatchesPending(pendingRequest, request); + const disabled = + isPending + || (action === "run" && !item.canRun) + || ((action === "start" || action === "restart") && !item.canStart); + + return ( + + ); + })} +
+ ); +} + +function CommandSection({ + title, + description, + items, + emptyMessage, + disabledHint, + isPending, + pendingRequest, + onAction, +}: { + title: string; + description: string; + items: WorkspaceRuntimeControlItem[]; + emptyMessage: string; + disabledHint?: string | null; + isPending: boolean; + pendingRequest: WorkspaceRuntimeControlRequest | null | undefined; + onAction: (request: WorkspaceRuntimeControlRequest) => void; +}) { + return ( +
+
+
{title}
+

{description}

+
+ {items.length === 0 ? ( +
+ {emptyMessage} + {disabledHint ?

{disabledHint}

: null} +
+ ) : ( +
+ {items.map((item) => ( +
+
+
+
+
{item.title}
+
+ {item.kind} · {item.statusLabel} + {item.lifecycle ? ` · ${item.lifecycle}` : ""} +
+
+ +
+
+ {item.url ? ( + + {item.url} + + + ) : null} + {item.port ?
Port {item.port}
: null} + {item.command ?
{item.command}
: null} + {item.cwd ?
{item.cwd}
: null} + {item.disabledReason ?
{item.disabledReason}
: null} +
+ {item.healthStatus ? ( +
+ + {item.healthStatus} + +
+ ) : null} +
+
+ ))} +
+ )} +
+ ); +} + export function WorkspaceRuntimeControls({ - isRunning, - canStart, + sections, + items, isPending = false, - pendingAction = null, + pendingRequest = null, + serviceEmptyMessage = "No services are configured for this workspace.", + jobEmptyMessage = "No one-shot jobs are configured for this workspace.", + emptyMessage, disabledHint = null, onAction, className, }: WorkspaceRuntimeControlsProps) { - const actions: WorkspaceRuntimeAction[] = isRunning ? ["stop", ...(canStart ? ["restart" as const] : [])] : ["start"]; - const statusCopy = isRunning - ? "Attached services are live for this workspace." - : canStart - ? "Ready to start attached services." - : "Add a working directory and runtime config to start services."; + const resolvedSections = sections ?? { + services: (items ?? []).map((item) => ({ + ...item, + statusLabel: item.statusLabel ?? item.status ?? "stopped", + })), + jobs: [], + otherServices: [], + }; + const resolvedServiceEmptyMessage = emptyMessage ?? serviceEmptyMessage; + const runningCount = resolvedSections.services.filter( + (item) => item.statusLabel === "running" || item.statusLabel === "starting", + ).length; return ( -
-
-
-
-
Runtime state
-
- - - {isRunning ? "Running" : "Stopped"} - - {statusCopy} -
-
-
- {actions.map((action) => { - const Icon = action === "start" ? Play : action === "stop" ? Square : RotateCcw; - const label = action === "start" ? "Start" : action === "stop" ? "Stop" : "Restart"; - const showSpinner = isPending && pendingAction === action; - const disabled = isPending || ((action === "start" || action === "restart") && !canStart); - - return ( - - ); - })} +
+
+
+
Workspace commands
+
+ 0 + ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" + : "border-border bg-background text-muted-foreground", + )} + > + + {runningCount > 0 ? `${runningCount} services running` : "No services running"} + + + {resolvedSections.jobs.length > 0 + ? `${resolvedSections.jobs.length} job${resolvedSections.jobs.length === 1 ? "" : "s"} available to run on demand.` + : "Each command can be controlled independently."} +
+ {disabledHint ?

{disabledHint}

: null}
- {!isRunning && !canStart && disabledHint ?

{disabledHint}

: null}
+ + + + + + {resolvedSections.otherServices.length > 0 ? ( + + ) : null}
); } diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index 462b0f9bc6b..b5748f92aa4 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -16,7 +16,7 @@ import { projectsApi } from "../api/projects"; import { IssuesList } from "../components/IssuesList"; import { PageTabBar } from "../components/PageTabBar"; import { - buildWorkspaceRuntimeControlItems, + buildWorkspaceRuntimeControlSections, WorkspaceRuntimeControls, type WorkspaceRuntimeControlRequest, } from "../components/WorkspaceRuntimeControls"; @@ -90,7 +90,7 @@ function parseWorkspaceRuntimeJson(value: string) { if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return { ok: false as const, - error: "Workspace runtime JSON must be a JSON object.", + error: "Workspace commands JSON must be a JSON object.", }; } return { ok: true as const, value: parsed as Record }; @@ -423,23 +423,25 @@ export function ExecutionWorkspaceDetail() { }); const controlRuntimeServices = useMutation({ mutationFn: (request: WorkspaceRuntimeControlRequest) => - executionWorkspacesApi.controlRuntimeServices(workspace!.id, request.action, request), + executionWorkspacesApi.controlRuntimeCommands(workspace!.id, request.action, request), onSuccess: (result, request) => { 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( - request.action === "stop" - ? "Runtime service stopped." - : request.action === "restart" - ? "Runtime service restarted." - : "Runtime service started.", + request.action === "run" + ? "Workspace job completed." + : request.action === "stop" + ? "Workspace service stopped." + : request.action === "restart" + ? "Workspace service restarted." + : "Workspace service started.", ); }, onError: (error) => { setRuntimeActionMessage(null); - setErrorMessage(error instanceof Error ? error.message : "Failed to control runtime services."); + setErrorMessage(error instanceof Error ? error.message : "Failed to control workspace commands."); }, }); @@ -453,11 +455,13 @@ export function ExecutionWorkspaceDetail() { } if (!workspace || !form || !initialState) return null; - const canStartRuntimeServices = Boolean(effectiveRuntimeConfig) && Boolean(workspace.cwd); - const runtimeControlItems = buildWorkspaceRuntimeControlItems({ + const canRunWorkspaceCommands = Boolean(workspace.cwd); + const canStartRuntimeServices = Boolean(effectiveRuntimeConfig) && canRunWorkspaceCommands; + const runtimeControlSections = buildWorkspaceRuntimeControlSections({ runtimeConfig: effectiveRuntimeConfig, runtimeServices: workspace.runtimeServices ?? [], canStartServices: canStartRuntimeServices, + canRunJobs: canRunWorkspaceCommands, }); const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null; @@ -529,8 +533,8 @@ export function ExecutionWorkspaceDetail() {
-
Runtime services
-

Attached services

+
Workspace commands
+

Services and jobs

Source: {runtimeConfigSource === "execution_workspace" ? "execution workspace override" @@ -542,15 +546,16 @@ export function ExecutionWorkspaceDetail() {

controlRuntimeServices.mutate(request)} /> {!errorMessage && runtimeActionMessage ?

{runtimeActionMessage}

: null} @@ -708,33 +713,41 @@ export function ExecutionWorkspaceDetail() {
- -
- { - const checked = event.target.checked; - setForm((current) => { - if (!current) return current; - if (!checked && !current.workspaceRuntime.trim() && inheritedRuntimeConfig) { - return { ...current, inheritRuntime: checked, workspaceRuntime: formatJson(inheritedRuntimeConfig) }; - } - return { ...current, inheritRuntime: checked }; - }); - }} - /> - +
+ Advanced runtime JSON +

+ Override the inherited workspace command model only when this execution workspace truly needs different service or job behavior. +

+
+ +
+ { + const checked = event.target.checked; + setForm((current) => { + if (!current) return current; + if (!checked && !current.workspaceRuntime.trim() && inheritedRuntimeConfig) { + return { ...current, inheritRuntime: checked, workspaceRuntime: formatJson(inheritedRuntimeConfig) }; + } + return { ...current, inheritRuntime: checked }; + }); + }} + /> + +
+