Refine workspace command controls

This commit is contained in:
Dotta committed 2026-04-14 11:51:38 -05:00
1 parent 335a8786de
commit e25a58d1a0
20 files changed
+1720 -222

No files matched your search

@@ -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.
+13
View File
@@ -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,
+4
View File
@@ -71,7 +71,11 @@ export type {
ExecutionWorkspaceCloseReadiness,
ExecutionWorkspaceCloseReadinessState,
ProjectWorkspaceRuntimeConfig,
WorkspaceCommandDefinition,
WorkspaceCommandKind,
WorkspaceRuntimeControlTarget,
WorkspaceRuntimeService,
WorkspaceRuntimeServiceStateMap,
WorkspaceRuntimeDesiredState,
ExecutionWorkspaceStrategyType,
ExecutionWorkspaceMode,
@@ -46,6 +46,27 @@ export type ExecutionWorkspaceCloseActionKind =
| "remove_local_directory";
export type WorkspaceRuntimeDesiredState = "running" | "stopped";
export type WorkspaceRuntimeServiceStateMap = Record<string, WorkspaceRuntimeDesiredState>;
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<string, unknown>;
source: WorkspaceCommandSource;
}
export interface ExecutionWorkspaceStrategy {
type: ExecutionWorkspaceStrategyType;
@@ -62,11 +83,19 @@ export interface ExecutionWorkspaceConfig {
cleanupCommand: string | null;
workspaceRuntime: Record<string, unknown> | null;
desiredState: WorkspaceRuntimeDesiredState | null;
serviceStates?: WorkspaceRuntimeServiceStateMap | null;
}
export interface ProjectWorkspaceRuntimeConfig {
workspaceRuntime: Record<string, unknown> | 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<string, unknown> | null;
healthStatus: "unknown" | "healthy" | "unhealthy";
configIndex?: number | null;
createdAt: Date;
updatedAt: Date;
}
@@ -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();
@@ -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"]);
@@ -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" }));
});
});
+204
View File
@@ -0,0 +1,204 @@
import type { WorkspaceCommandDefinition, WorkspaceRuntimeService } from "./types/workspace-runtime.js";
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>;
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<string>,
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<string, unknown> | null | undefined,
key: "commands" | "services" | "jobs",
) {
const raw = workspaceRuntime?.[key];
return Array.isArray(raw) ? raw.filter((entry): entry is Record<string, unknown> => isRecord(entry)) : [];
}
export function listWorkspaceCommandDefinitions(
workspaceRuntime: Record<string, unknown> | null | undefined,
): WorkspaceCommandDefinition[] {
if (!workspaceRuntime) return [];
const commandEntries = readCommandEntries(workspaceRuntime, "commands");
const seenIds = new Set<string>();
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<string, unknown> | null | undefined,
) {
return listWorkspaceCommandDefinitions(workspaceRuntime).filter((command) => command.kind === "service");
}
export function findWorkspaceCommandDefinition(
workspaceRuntime: Record<string, unknown> | 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<WorkspaceCommandDefinition, "serviceIndex" | "name" | "command" | "cwd">,
runtimeService: Pick<WorkspaceRuntimeService, "configIndex" | "serviceName" | "command" | "cwd">,
) {
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<WorkspaceRuntimeService, "configIndex" | "serviceName" | "command" | "cwd">,
>(
command: Pick<WorkspaceCommandDefinition, "serviceIndex" | "name" | "command" | "cwd">,
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;
}
@@ -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)", () => {
+151 -11
View File
@@ -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<string, unknown> | 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<string, "running" | "stopped"> | 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<string, unknown> | 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;
+145 -11
View File
@@ -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<string, "running" | "stopped"> | 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;
@@ -192,6 +192,11 @@ export function readExecutionWorkspaceConfig(metadata: Record<string, unknown> |
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;
@@ -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<string, unknown> | 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;
+218 -21
View File
@@ -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<string, unknown>;
workspace: RealizedExecutionWorkspace;
agent: ExecutionWorkspaceAgentRef;
issue: ExecutionWorkspaceIssueRef | null;
adapterEnv: Record<string, string>;
}) {
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<string, string>;
return {
name,
command,
cwd,
env,
};
}
export async function runWorkspaceJobForControl(input: {
actor: ExecutionWorkspaceAgentRef;
issue: ExecutionWorkspaceIssueRef | null;
workspace: RealizedExecutionWorkspace;
command: Record<string, unknown>;
adapterEnv?: Record<string, string>;
recorder?: WorkspaceOperationRecorder | null;
metadata?: Record<string, unknown> | 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<string, unknown>;
workspace: RealizedExecutionWorkspace;
@@ -1989,10 +2071,78 @@ function registerRuntimeService(db: Db | undefined, record: RuntimeServiceRecord
}
function readRuntimeServiceEntries(config: Record<string, unknown>) {
const runtime = parseObject(config.workspaceRuntime);
return Array.isArray(runtime.services)
? runtime.services.filter((entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null)
: [];
return listWorkspaceServiceCommandDefinitions(parseObject(config.workspaceRuntime))
.map((command) => command.rawConfig);
}
export function listConfiguredRuntimeServiceEntries(config: Record<string, unknown>) {
return readRuntimeServiceEntries(config);
}
function readConfiguredServiceStates(config: Record<string, unknown>) {
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
adapterEnv: Record<string, string>;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
serviceIndex?: number | null;
respectDesiredStates?: boolean;
}): Promise<RuntimeServiceRef[]> {
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<string, unknown> | 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<string, unknown> | null) ?? null);
if (config?.desiredState !== "running" || !config.workspaceRuntime || !row.cwd) continue;
const inheritedRuntimeConfig = row.projectWorkspaceId
? readProjectWorkspaceRuntimeConfig(
(projectWorkspaceRowsById.get(row.projectWorkspaceId)?.metadata as Record<string, unknown> | 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 {
+21 -3
View File
@@ -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<ExecutionWorkspaceCloseReadiness>(`/execution-workspaces/${id}/close-readiness`),
listWorkspaceOperations: (id: string) =>
api.get<WorkspaceOperation[]>(`/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<string, unknown>) => api.patch<ExecutionWorkspace>(`/execution-workspaces/${id}`, data),
};
+19 -2
View File
@@ -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<ProjectWorkspace>(projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`)),
@@ -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> = {}): 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(<WorkspaceRuntimeControls isRunning={false} canStart onAction={vi.fn()} />);
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(<WorkspaceRuntimeControls isRunning canStart onAction={vi.fn()} />);
});
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(
<WorkspaceRuntimeControls
isRunning={false}
canStart={false}
disabledHint="Add runtime settings first."
sections={sections}
onAction={vi.fn()}
/>,
);
});
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(
<WorkspaceRuntimeControls
sections={sections}
disabledHint="Add a workspace path first."
onAction={vi.fn()}
/>,
);
});
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());
});
});
+401 -60
View File
@@ -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<string, unknown> | null | undefined;
runtimeServices: WorkspaceRuntimeService[] | null | undefined;
canStartServices: boolean;
canRunJobs?: boolean;
}): WorkspaceRuntimeControlSections {
const commands = listWorkspaceCommandDefinitions(input.runtimeConfig);
const runtimeServices = [...(input.runtimeServices ?? [])];
const matchedRuntimeServiceIds = new Set<string>();
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<string, unknown> | 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 (
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
{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 (
<Button
key={`${item.key}:${action}`}
variant={action === "stop" ? "destructive" : action === "restart" ? "outline" : "default"}
size="sm"
className={cn(
"h-9 w-full justify-start rounded-xl px-3 shadow-none sm:w-auto",
action === "restart" ? "bg-background" : null,
)}
disabled={disabled}
onClick={() => onAction(request)}
>
{showSpinner ? <Loader2 className="h-4 w-4 animate-spin" /> : <Icon className="h-4 w-4" />}
{label}
</Button>
);
})}
</div>
);
}
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 (
<div className="space-y-3">
<div className="space-y-1">
<div className="text-sm font-medium">{title}</div>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
{items.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/80 bg-background/50 px-3 py-4 text-sm text-muted-foreground">
{emptyMessage}
{disabledHint ? <p className="mt-2 text-xs">{disabledHint}</p> : null}
</div>
) : (
<div className="space-y-3">
{items.map((item) => (
<div key={item.key} className="rounded-xl border border-border/80 bg-background px-3 py-3">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">{item.title}</div>
<div className="text-xs text-muted-foreground">
{item.kind} · {item.statusLabel}
{item.lifecycle ? ` · ${item.lifecycle}` : ""}
</div>
</div>
<CommandActionButtons
item={item}
isPending={isPending}
pendingRequest={pendingRequest}
onAction={onAction}
/>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
{item.url ? (
<a href={item.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
{item.url}
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : null}
{item.port ? <div>Port {item.port}</div> : null}
{item.command ? <div className="break-all font-mono">{item.command}</div> : null}
{item.cwd ? <div className="break-all font-mono">{item.cwd}</div> : null}
{item.disabledReason ? <div>{item.disabledReason}</div> : null}
</div>
{item.healthStatus ? (
<div className="flex items-center gap-2">
<span className="inline-flex items-center rounded-full border border-border px-2.5 py-1 text-[11px] text-muted-foreground">
{item.healthStatus}
</span>
</div>
) : null}
</div>
</div>
))}
</div>
)}
</div>
);
}
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 (
<div className={cn("rounded-xl border border-border/70 bg-background/60 p-3", className)}>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Runtime state</div>
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
isRunning
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "border-border bg-background text-muted-foreground",
)}
>
<Activity className="h-3.5 w-3.5" />
{isRunning ? "Running" : "Stopped"}
</span>
<span className="text-xs text-muted-foreground">{statusCopy}</span>
</div>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
{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 (
<Button
key={action}
variant={action === "start" ? "default" : action === "stop" ? "destructive" : "outline"}
size="sm"
className={cn(
"h-10 w-full justify-start rounded-xl px-3 shadow-none sm:w-auto",
action === "restart" ? "bg-background" : null,
)}
disabled={disabled}
onClick={() => onAction(action)}
>
{showSpinner ? <Loader2 className="h-4 w-4 animate-spin" /> : <Icon className="h-4 w-4" />}
{label}
</Button>
);
})}
<div className={cn("space-y-4", className)}>
<div className="rounded-xl border border-border/70 bg-background/60 p-3">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Workspace commands</div>
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
runningCount > 0
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "border-border bg-background text-muted-foreground",
)}
>
<Activity className="h-3.5 w-3.5" />
{runningCount > 0 ? `${runningCount} services running` : "No services running"}
</span>
<span className="text-xs text-muted-foreground">
{resolvedSections.jobs.length > 0
? `${resolvedSections.jobs.length} job${resolvedSections.jobs.length === 1 ? "" : "s"} available to run on demand.`
: "Each command can be controlled independently."}
</span>
</div>
{disabledHint ? <p className="text-xs text-muted-foreground">{disabledHint}</p> : null}
</div>
{!isRunning && !canStart && disabledHint ? <p className="text-xs text-muted-foreground">{disabledHint}</p> : null}
</div>
<CommandSection
title="Services"
description="Long-running commands that Paperclip can supervise for this workspace."
items={resolvedSections.services}
emptyMessage={resolvedServiceEmptyMessage}
disabledHint={disabledHint}
isPending={isPending}
pendingRequest={pendingRequest}
onAction={onAction}
/>
<CommandSection
title="Jobs"
description="One-shot commands that run now and exit when they finish."
items={resolvedSections.jobs}
emptyMessage={jobEmptyMessage}
isPending={isPending}
pendingRequest={pendingRequest}
onAction={onAction}
/>
{resolvedSections.otherServices.length > 0 ? (
<CommandSection
title="Untracked services"
description="Running services that no longer match the current workspace command config."
items={resolvedSections.otherServices}
emptyMessage=""
isPending={isPending}
pendingRequest={pendingRequest}
onAction={onAction}
/>
) : null}
</div>
);
}
+57 -44
View File
@@ -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<string, unknown> };
@@ -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() {
<div className="rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Runtime services</div>
<h2 className="text-lg font-semibold">Attached services</h2>
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Workspace commands</div>
<h2 className="text-lg font-semibold">Services and jobs</h2>
<p className="text-sm text-muted-foreground">
Source: {runtimeConfigSource === "execution_workspace"
? "execution workspace override"
@@ -542,15 +546,16 @@ export function ExecutionWorkspaceDetail() {
</div>
<WorkspaceRuntimeControls
className="mt-4"
items={runtimeControlItems}
sections={runtimeControlSections}
isPending={controlRuntimeServices.isPending}
pendingRequest={pendingRuntimeAction}
emptyMessage={
serviceEmptyMessage={
effectiveRuntimeConfig
? "No runtime services have been started for this execution workspace yet."
: "No runtime config is defined for this execution workspace yet."
? "No services have been started for this execution workspace yet."
: "No workspace command config is defined for this execution workspace yet."
}
disabledHint="Execution workspaces need both a working directory and runtime config before services can be started."
jobEmptyMessage="No one-shot jobs are configured for this execution workspace yet."
disabledHint="Execution workspaces need a working directory before local commands can run, and services also need runtime config."
onAction={(request) => controlRuntimeServices.mutate(request)}
/>
{!errorMessage && runtimeActionMessage ? <p className="mt-4 text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
@@ -708,33 +713,41 @@ export function ExecutionWorkspaceDetail() {
</div>
</div>
<Field label="Runtime services JSON" hint="Concrete workspace runtime settings for this execution workspace. Leave this inheriting unless you need a one-off override. If you are missing the right commands, ask your CEO to set them up for you.">
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
<input
id="inherit-runtime-config"
type="checkbox"
checked={form.inheritRuntime}
onChange={(event) => {
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 };
});
}}
/>
<label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label>
<details className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-3 py-3">
<summary className="cursor-pointer text-sm font-medium">Advanced runtime JSON</summary>
<p className="mt-2 text-sm text-muted-foreground">
Override the inherited workspace command model only when this execution workspace truly needs different service or job behavior.
</p>
<div className="mt-3">
<Field label="Workspace commands JSON" hint="Legacy `services` arrays still work, but `commands` supports both services and jobs.">
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
<input
id="inherit-runtime-config"
type="checkbox"
checked={form.inheritRuntime}
onChange={(event) => {
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 };
});
}}
/>
<label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label>
</div>
<textarea
className="min-h-64 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60 sm:min-h-96"
value={form.workspaceRuntime}
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
disabled={form.inheritRuntime}
placeholder={'{\n "commands": [\n {\n "id": "web",\n "name": "web",\n "kind": "service",\n "command": "pnpm dev",\n "cwd": ".",\n "port": { "type": "auto" }\n },\n {\n "id": "db-migrate",\n "name": "db:migrate",\n "kind": "job",\n "command": "pnpm db:migrate",\n "cwd": "."\n }\n ]\n}'}
/>
</Field>
</div>
<textarea
className="min-h-64 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60 sm:min-h-96"
value={form.workspaceRuntime}
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
disabled={form.inheritRuntime}
placeholder={'{\n "services": [\n {\n "name": "web",\n "command": "pnpm dev",\n "port": 3100\n }\n ]\n}'}
/>
</Field>
</details>
</div>
<div className="mt-5 flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center">
+40 -27
View File
@@ -8,7 +8,7 @@ import { Separator } from "@/components/ui/separator";
import { ChoosePathButton } from "../components/PathInstructionsModal";
import { projectsApi } from "../api/projects";
import {
buildWorkspaceRuntimeControlItems,
buildWorkspaceRuntimeControlSections,
WorkspaceRuntimeControls,
type WorkspaceRuntimeControlRequest,
} from "../components/WorkspaceRuntimeControls";
@@ -103,7 +103,7 @@ function parseRuntimeConfigJson(value: string) {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {
ok: false as const,
error: "Runtime services JSON must be a JSON object.",
error: "Workspace commands JSON must be a JSON object.",
};
}
return { ok: true as const, value: parsed as Record<string, unknown> };
@@ -309,21 +309,23 @@ export function ProjectWorkspaceDetail() {
const controlRuntimeServices = useMutation({
mutationFn: (request: WorkspaceRuntimeControlRequest) =>
projectsApi.controlWorkspaceRuntimeServices(project!.id, routeWorkspaceId, request.action, lookupCompanyId, request),
projectsApi.controlWorkspaceCommands(project!.id, routeWorkspaceId, request.action, lookupCompanyId, request),
onSuccess: (result, request) => {
invalidateProject();
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.");
},
});
@@ -339,11 +341,13 @@ export function ProjectWorkspaceDetail() {
return <p className="text-sm text-muted-foreground">Workspace not found for this project.</p>;
}
const canStartRuntimeServices = Boolean(workspace.runtimeConfig?.workspaceRuntime) && Boolean(workspace.cwd);
const runtimeControlItems = buildWorkspaceRuntimeControlItems({
const canRunWorkspaceCommands = Boolean(workspace.cwd);
const canStartRuntimeServices = Boolean(workspace.runtimeConfig?.workspaceRuntime) && canRunWorkspaceCommands;
const runtimeControlSections = buildWorkspaceRuntimeControlSections({
runtimeConfig: workspace.runtimeConfig?.workspaceRuntime ?? null,
runtimeServices: workspace.runtimeServices ?? [],
canStartServices: canStartRuntimeServices,
canRunJobs: canRunWorkspaceCommands,
});
const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null;
@@ -541,14 +545,22 @@ export function ProjectWorkspaceDetail() {
</Field>
</div>
<Field label="Runtime services JSON" hint="Default runtime services for this workspace. Execution workspaces inherit this config unless they set an override. If you do not know the commands yet, ask your CEO to configure them for you.">
<textarea
className="min-h-96 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.runtimeConfig}
onChange={(event) => setForm((current) => current ? { ...current, runtimeConfig: event.target.value } : current)}
placeholder={"{\n \"services\": [\n {\n \"name\": \"web\",\n \"command\": \"pnpm dev\",\n \"cwd\": \".\",\n \"port\": { \"type\": \"auto\" },\n \"readiness\": {\n \"type\": \"http\",\n \"urlTemplate\": \"http://127.0.0.1:${port}\"\n },\n \"expose\": {\n \"type\": \"url\",\n \"urlTemplate\": \"http://127.0.0.1:${port}\"\n },\n \"lifecycle\": \"shared\",\n \"reuseScope\": \"project_workspace\"\n }\n ]\n}"}
/>
</Field>
<details className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-3 py-3">
<summary className="cursor-pointer text-sm font-medium">Advanced runtime JSON</summary>
<p className="mt-2 text-sm text-muted-foreground">
Paperclip derives Services and Jobs from this JSON. Prefer editing named commands first; use raw JSON for advanced lifecycle, port, readiness, or environment settings.
</p>
<div className="mt-3">
<Field label="Workspace commands JSON" hint="Execution workspaces inherit this config unless they override it. Legacy `services` arrays still work, but `commands` supports both services and jobs.">
<textarea
className="min-h-96 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.runtimeConfig}
onChange={(event) => setForm((current) => current ? { ...current, runtimeConfig: event.target.value } : current)}
placeholder={"{\n \"commands\": [\n {\n \"id\": \"web\",\n \"name\": \"web\",\n \"kind\": \"service\",\n \"command\": \"pnpm dev\",\n \"cwd\": \".\",\n \"port\": { \"type\": \"auto\" },\n \"readiness\": {\n \"type\": \"http\",\n \"urlTemplate\": \"http://127.0.0.1:${port}\"\n },\n \"expose\": {\n \"type\": \"url\",\n \"urlTemplate\": \"http://127.0.0.1:${port}\"\n },\n \"lifecycle\": \"shared\",\n \"reuseScope\": \"project_workspace\"\n },\n {\n \"id\": \"db-migrate\",\n \"name\": \"db:migrate\",\n \"kind\": \"job\",\n \"command\": \"pnpm db:migrate\",\n \"cwd\": \".\"\n }\n ]\n}"}
/>
</Field>
</div>
</details>
</div>
<div className="mt-5 flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center">
@@ -607,24 +619,25 @@ export function ProjectWorkspaceDetail() {
<div className="rounded-2xl border border-border bg-card p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Runtime services</div>
<h2 className="text-lg font-semibold">Attached services</h2>
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Workspace commands</div>
<h2 className="text-lg font-semibold">Services and jobs</h2>
<p className="text-sm text-muted-foreground">
Shared services for this project workspace. Execution workspaces inherit this config unless they override it.
Long-running services stay supervised here, while one-shot jobs run on demand against this workspace. Execution workspaces inherit this config unless they override it.
</p>
</div>
</div>
<WorkspaceRuntimeControls
className="mt-4"
items={runtimeControlItems}
sections={runtimeControlSections}
isPending={controlRuntimeServices.isPending}
pendingRequest={pendingRuntimeAction}
emptyMessage={
serviceEmptyMessage={
workspace.runtimeConfig?.workspaceRuntime
? "No runtime services have been started for this workspace yet."
: "No runtime-service default is configured for this workspace yet."
? "No services have been started for this workspace yet."
: "No workspace command config is defined for this workspace yet."
}
disabledHint="Project workspaces need both a working directory and runtime config before services can be started."
jobEmptyMessage="No one-shot jobs are configured for this workspace yet."
disabledHint="Project workspaces need a working directory before local commands can run, and services also need runtime config."
onAction={(request) => controlRuntimeServices.mutate(request)}
/>
</div>