Add workspace runtime controls

Expose project and execution workspace runtime defaults, control endpoints, startup recovery, and operator UI for start/stop/restart flows.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-28 16:46:43 -05:00
parent f1ad07616c
commit 1f1fe9c989
25 changed files with 1133 additions and 51 deletions

View file

@ -1,4 +1,4 @@
import type { ExecutionWorkspace, ExecutionWorkspaceCloseReadiness } from "@paperclipai/shared";
import type { ExecutionWorkspace, ExecutionWorkspaceCloseReadiness, WorkspaceOperation } from "@paperclipai/shared";
import { api } from "./client";
export const executionWorkspacesApi = {
@ -24,5 +24,12 @@ export const executionWorkspacesApi = {
get: (id: string) => api.get<ExecutionWorkspace>(`/execution-workspaces/${id}`),
getCloseReadiness: (id: string) =>
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") =>
api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>(
`/execution-workspaces/${id}/runtime-services/${action}`,
{},
),
update: (id: string, data: Record<string, unknown>) => api.patch<ExecutionWorkspace>(`/execution-workspaces/${id}`, data),
};

View file

@ -1,4 +1,4 @@
import type { Project, ProjectWorkspace } from "@paperclipai/shared";
import type { Project, ProjectWorkspace, WorkspaceOperation } from "@paperclipai/shared";
import { api } from "./client";
function withCompanyScope(path: string, companyId?: string) {
@ -27,6 +27,16 @@ export const projectsApi = {
projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`),
data,
),
controlWorkspaceRuntimeServices: (
projectId: string,
workspaceId: string,
action: "start" | "stop" | "restart",
companyId?: string,
) =>
api.post<{ workspace: ProjectWorkspace; operation: WorkspaceOperation }>(
projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}/runtime-services/${action}`),
{},
),
removeWorkspace: (projectId: string, workspaceId: string, companyId?: string) =>
api.delete<ProjectWorkspace>(projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`)),
remove: (id: string, companyId?: string) => api.delete<Project>(projectPath(id, companyId)),

View file

@ -20,6 +20,7 @@ function createProjectWorkspace(overrides: Partial<ProjectWorkspace>): ProjectWo
remoteWorkspaceRef: overrides.remoteWorkspaceRef ?? null,
sharedWorkspaceKey: overrides.sharedWorkspaceKey ?? null,
metadata: overrides.metadata ?? null,
runtimeConfig: overrides.runtimeConfig ?? null,
isPrimary: overrides.isPrimary ?? false,
runtimeServices: overrides.runtimeServices ?? [],
createdAt: overrides.createdAt ?? new Date("2026-03-20T00:00:00Z"),
@ -151,7 +152,7 @@ describe("buildProjectWorkspaceSummaries", () => {
],
});
expect(summaries).toHaveLength(2);
expect(summaries).toHaveLength(3);
expect(summaries[0]).toMatchObject({
key: "execution:exec-1",
kind: "execution_workspace",
@ -172,6 +173,7 @@ describe("buildProjectWorkspaceSummaries", () => {
"issue-feature-newer",
"issue-feature-older",
]);
expect(summaries[2]?.key).toBe("project:workspace-default");
});
it("does not duplicate non-primary workspace issues when an execution workspace owns them", () => {
@ -194,8 +196,9 @@ describe("buildProjectWorkspaceSummaries", () => {
],
});
expect(summaries).toHaveLength(1);
expect(summaries).toHaveLength(2);
expect(summaries[0]?.key).toBe("execution:exec-2");
expect(summaries[1]?.key).toBe("project:workspace-default");
});
it("excludes issues that only use the default shared workspace", () => {
@ -222,6 +225,7 @@ describe("buildProjectWorkspaceSummaries", () => {
],
});
expect(summaries).toHaveLength(0);
expect(summaries).toHaveLength(1);
expect(summaries[0]?.key).toBe("project:workspace-default");
});
});

View file

@ -13,6 +13,10 @@ export interface ProjectWorkspaceSummary {
projectWorkspaceId: string | null;
executionWorkspaceId: string | null;
executionWorkspaceStatus: ExecutionWorkspace["status"] | null;
serviceCount: number;
runningServiceCount: number;
primaryServiceUrl: string | null;
hasRuntimeConfig: boolean;
issues: Issue[];
}
@ -94,6 +98,13 @@ export function buildProjectWorkspaceSummaries(input: {
projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null,
executionWorkspaceId: executionWorkspace.id,
executionWorkspaceStatus: executionWorkspace.status,
serviceCount: executionWorkspace.runtimeServices?.length ?? 0,
runningServiceCount: executionWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
primaryServiceUrl: executionWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
hasRuntimeConfig: Boolean(
executionWorkspace.config?.workspaceRuntime
?? projectWorkspacesById.get(executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? "")?.runtimeConfig?.workspaceRuntime,
),
issues: nextIssues,
});
continue;
@ -119,10 +130,41 @@ export function buildProjectWorkspaceSummaries(input: {
projectWorkspaceId: projectWorkspace.id,
executionWorkspaceId: null,
executionWorkspaceStatus: null,
serviceCount: projectWorkspace.runtimeServices?.length ?? 0,
runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
issues: nextIssues,
});
}
for (const projectWorkspace of input.project.workspaces) {
const key = `project:${projectWorkspace.id}`;
if (summaries.has(key)) continue;
const shouldSurfaceWorkspace =
projectWorkspace.isPrimary
|| Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime)
|| (projectWorkspace.runtimeServices?.length ?? 0) > 0;
if (!shouldSurfaceWorkspace) continue;
summaries.set(key, {
key,
kind: "project_workspace",
workspaceId: projectWorkspace.id,
workspaceName: projectWorkspace.name,
cwd: projectWorkspace.cwd ?? null,
branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null,
lastUpdatedAt: maxDate(projectWorkspace.updatedAt),
projectWorkspaceId: projectWorkspace.id,
executionWorkspaceId: null,
executionWorkspaceStatus: null,
serviceCount: projectWorkspace.runtimeServices?.length ?? 0,
runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
issues: [],
});
}
return [...summaries.values()].sort((a, b) => {
const diff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime();
return diff !== 0 ? diff : a.workspaceName.localeCompare(b.workspaceName);

View file

@ -62,6 +62,7 @@ export const queryKeys = {
["execution-workspaces", companyId, filters ?? {}] as const,
detail: (id: string) => ["execution-workspaces", "detail", id] as const,
closeReadiness: (id: string) => ["execution-workspaces", "close-readiness", id] as const,
workspaceOperations: (id: string) => ["execution-workspaces", "workspace-operations", id] as const,
},
projects: {
list: (companyId: string) => ["projects", companyId] as const,

View file

@ -25,6 +25,7 @@ type WorkspaceFormState = {
provisionCommand: string;
teardownCommand: string;
cleanupCommand: string;
inheritRuntime: boolean;
workspaceRuntime: string;
};
@ -84,6 +85,7 @@ function formStateFromWorkspace(workspace: ExecutionWorkspace): WorkspaceFormSta
provisionCommand: readText(workspace.config?.provisionCommand),
teardownCommand: readText(workspace.config?.teardownCommand),
cleanupCommand: readText(workspace.config?.cleanupCommand),
inheritRuntime: !workspace.config?.workspaceRuntime,
workspaceRuntime: formatJson(workspace.config?.workspaceRuntime),
};
}
@ -115,10 +117,10 @@ function buildWorkspacePatch(initialState: WorkspaceFormState, nextState: Worksp
maybeAssignConfigText("teardownCommand");
maybeAssignConfigText("cleanupCommand");
if (initialState.workspaceRuntime !== nextState.workspaceRuntime) {
if (initialState.inheritRuntime !== nextState.inheritRuntime || initialState.workspaceRuntime !== nextState.workspaceRuntime) {
const parsed = parseWorkspaceRuntimeJson(nextState.workspaceRuntime);
if (!parsed.ok) throw new Error(parsed.error);
configPatch.workspaceRuntime = parsed.value;
configPatch.workspaceRuntime = nextState.inheritRuntime ? null : parsed.value;
}
if (Object.keys(configPatch).length > 0) {
@ -138,9 +140,11 @@ function validateForm(form: WorkspaceFormState) {
}
}
const runtimeJson = parseWorkspaceRuntimeJson(form.workspaceRuntime);
if (!runtimeJson.ok) {
return runtimeJson.error;
if (!form.inheritRuntime) {
const runtimeJson = parseWorkspaceRuntimeJson(form.workspaceRuntime);
if (!runtimeJson.ok) {
return runtimeJson.error;
}
}
return null;
@ -214,6 +218,7 @@ export function ExecutionWorkspaceDetail() {
const [form, setForm] = useState<WorkspaceFormState | null>(null);
const [closeDialogOpen, setCloseDialogOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
const workspaceQuery = useQuery({
queryKey: queryKeys.executionWorkspaces.detail(workspaceId!),
@ -249,6 +254,14 @@ export function ExecutionWorkspaceDetail() {
() => project?.workspaces.find((item) => item.id === workspace?.projectWorkspaceId) ?? null,
[project, workspace?.projectWorkspaceId],
);
const inheritedRuntimeConfig = linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime ?? null;
const effectiveRuntimeConfig = workspace?.config?.workspaceRuntime ?? inheritedRuntimeConfig;
const runtimeConfigSource =
workspace?.config?.workspaceRuntime
? "execution_workspace"
: inheritedRuntimeConfig
? "project_workspace"
: "none";
const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]);
const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState));
@ -281,6 +294,7 @@ export function ExecutionWorkspaceDetail() {
onSuccess: (nextWorkspace) => {
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(nextWorkspace.id), nextWorkspace);
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(nextWorkspace.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(nextWorkspace.id) });
if (project) {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.urlKey) });
@ -294,6 +308,32 @@ export function ExecutionWorkspaceDetail() {
setErrorMessage(error instanceof Error ? error.message : "Failed to save execution workspace.");
},
});
const workspaceOperationsQuery = useQuery({
queryKey: queryKeys.executionWorkspaces.workspaceOperations(workspaceId!),
queryFn: () => executionWorkspacesApi.listWorkspaceOperations(workspaceId!),
enabled: Boolean(workspaceId),
});
const controlRuntimeServices = useMutation({
mutationFn: (action: "start" | "stop" | "restart") =>
executionWorkspacesApi.controlRuntimeServices(workspace!.id, action),
onSuccess: (result, action) => {
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(result.workspace.id), result.workspace);
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(result.workspace.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(result.workspace.projectId) });
setErrorMessage(null);
setRuntimeActionMessage(
action === "stop"
? "Runtime services stopped."
: action === "restart"
? "Runtime services restarted."
: "Runtime services started.",
);
},
onError: (error) => {
setRuntimeActionMessage(null);
setErrorMessage(error instanceof Error ? error.message : "Failed to control runtime services.");
},
});
if (workspaceQuery.isLoading) return <p className="text-sm text-muted-foreground">Loading workspace</p>;
if (workspaceQuery.error) {
@ -455,11 +495,54 @@ export function ExecutionWorkspaceDetail() {
/>
</Field>
<Field label="Runtime services JSON" hint="Concrete workspace runtime settings, including services">
<div className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
Runtime config source
</div>
<p className="mt-1 text-sm text-muted-foreground">
{runtimeConfigSource === "execution_workspace"
? "This execution workspace currently overrides the project workspace runtime config."
: runtimeConfigSource === "project_workspace"
? "This execution workspace is inheriting the project workspace runtime config."
: "No runtime config is currently defined on this execution workspace or its project workspace."}
</p>
</div>
<Button
variant="outline"
size="sm"
disabled={!linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime}
onClick={() =>
setForm((current) => current ? {
...current,
inheritRuntime: true,
workspaceRuntime: "",
} : current)
}
>
Reset to inherit
</Button>
</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) =>
setForm((current) => current ? { ...current, inheritRuntime: event.target.checked } : current)
}
/>
<label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label>
</div>
<textarea
className="min-h-48 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
className="min-h-48 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"
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>
@ -476,11 +559,13 @@ export function ExecutionWorkspaceDetail() {
onClick={() => {
setForm(initialState);
setErrorMessage(null);
setRuntimeActionMessage(null);
}}
>
Reset
</Button>
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
{!errorMessage && runtimeActionMessage ? <p className="text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
{!errorMessage && !isDirty ? <p className="text-sm text-muted-foreground">No unsaved changes.</p> : null}
</div>
</div>
@ -577,9 +662,45 @@ export function ExecutionWorkspaceDetail() {
</div>
<div className="rounded-2xl border border-border bg-card p-5">
<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="flex items-start justify-between gap-3">
<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>
<p className="text-sm text-muted-foreground">
Source: {runtimeConfigSource === "execution_workspace"
? "execution workspace override"
: runtimeConfigSource === "project_workspace"
? "project workspace default"
: "none"}
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={controlRuntimeServices.isPending || !effectiveRuntimeConfig || !workspace.cwd}
onClick={() => controlRuntimeServices.mutate("start")}
>
{controlRuntimeServices.isPending ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Start
</Button>
<Button
variant="outline"
size="sm"
disabled={controlRuntimeServices.isPending || !effectiveRuntimeConfig || !workspace.cwd}
onClick={() => controlRuntimeServices.mutate("restart")}
>
Restart
</Button>
<Button
variant="outline"
size="sm"
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0}
onClick={() => controlRuntimeServices.mutate("stop")}
>
Stop
</Button>
</div>
</div>
<Separator className="my-4" />
{workspace.runtimeServices && workspace.runtimeServices.length > 0 ? (
@ -597,6 +718,7 @@ export function ExecutionWorkspaceDetail() {
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : null}
{service.port ? <div>Port {service.port}</div> : null}
{service.command ? <MonoValue value={service.command} copy /> : null}
{service.cwd ? <MonoValue value={service.cwd} copy /> : null}
</div>
@ -607,7 +729,52 @@ export function ExecutionWorkspaceDetail() {
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No runtime services are attached to this execution workspace.</p>
<p className="text-sm text-muted-foreground">
{effectiveRuntimeConfig
? "No runtime services are currently running for this execution workspace."
: "No runtime config is defined for this execution workspace yet."}
</p>
)}
</div>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Recent operations</div>
<h2 className="text-lg font-semibold">Runtime and cleanup logs</h2>
</div>
<Separator className="my-4" />
{workspaceOperationsQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading workspace operations</p>
) : workspaceOperationsQuery.error ? (
<p className="text-sm text-destructive">
{workspaceOperationsQuery.error instanceof Error
? workspaceOperationsQuery.error.message
: "Failed to load workspace operations."}
</p>
) : workspaceOperationsQuery.data && workspaceOperationsQuery.data.length > 0 ? (
<div className="space-y-3">
{workspaceOperationsQuery.data.slice(0, 6).map((operation) => (
<div key={operation.id} className="rounded-xl border border-border/80 bg-background px-3 py-2">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="text-sm font-medium">{operation.command ?? operation.phase}</div>
<div className="text-xs text-muted-foreground">
{formatDateTime(operation.startedAt)}
{operation.finishedAt ? `${formatDateTime(operation.finishedAt)}` : ""}
</div>
{operation.stderrExcerpt ? (
<div className="whitespace-pre-wrap break-words text-xs text-destructive">{operation.stderrExcerpt}</div>
) : operation.stdoutExcerpt ? (
<div className="whitespace-pre-wrap break-words text-xs text-muted-foreground">{operation.stdoutExcerpt}</div>
) : null}
</div>
<StatusPill>{operation.status}</StatusPill>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No workspace operations have been recorded yet.</p>
)}
</div>
</div>
@ -622,6 +789,7 @@ export function ExecutionWorkspaceDetail() {
onClosed={(nextWorkspace) => {
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(nextWorkspace.id), nextWorkspace);
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(nextWorkspace.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(nextWorkspace.id) });
if (project) {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(project.companyId, { projectId: project.id }) });

View file

@ -31,7 +31,7 @@ import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import { Clock3, Copy, GitBranch } from "lucide-react";
import { Clock3, Copy, GitBranch, Loader2 } from "lucide-react";
/* ── Top-level tab types ── */
@ -221,11 +221,32 @@ function ProjectWorkspacesContent({
summaries: ReturnType<typeof buildProjectWorkspaceSummaries>;
}) {
const queryClient = useQueryClient();
const [runtimeActionKey, setRuntimeActionKey] = useState<string | null>(null);
const [closingWorkspace, setClosingWorkspace] = useState<{
id: string;
name: string;
status: ExecutionWorkspace["status"];
} | null>(null);
const controlWorkspaceRuntime = useMutation({
mutationFn: async (input: {
key: string;
kind: "project_workspace" | "execution_workspace";
workspaceId: string;
action: "start" | "stop" | "restart";
}) => {
setRuntimeActionKey(`${input.key}:${input.action}`);
if (input.kind === "project_workspace") {
return await projectsApi.controlWorkspaceRuntimeServices(projectId, input.workspaceId, input.action, companyId);
}
return await executionWorkspacesApi.controlRuntimeServices(input.workspaceId, input.action);
},
onSettled: () => {
setRuntimeActionKey(null);
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
},
});
if (summaries.length === 0) {
return <p className="text-sm text-muted-foreground">No non-default workspace activity yet.</p>;
@ -261,12 +282,25 @@ function ProjectWorkspacesContent({
<GitBranch className="h-3.5 w-3.5" />
<span className="font-mono">{summary.branchName ?? "No branch info"}</span>
</span>
<span className="rounded-full border border-border px-2 py-0.5 text-[11px]">
{summary.runningServiceCount}/{summary.serviceCount} services running
</span>
{summary.executionWorkspaceStatus ? (
<span className="rounded-full border border-border px-2 py-0.5 text-[11px]">
{summary.executionWorkspaceStatus}
</span>
) : null}
</div>
{summary.primaryServiceUrl ? (
<a
href={summary.primaryServiceUrl}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground hover:underline"
>
{summary.primaryServiceUrl}
</a>
) : null}
{summary.cwd ? (
<div className="mt-2 flex min-w-0 items-start gap-2 text-xs text-muted-foreground">
@ -312,6 +346,43 @@ function ProjectWorkspacesContent({
>
{summary.kind === "project_workspace" ? "Configure workspace" : "View workspace"}
</Link>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={
controlWorkspaceRuntime.isPending
|| !summary.hasRuntimeConfig
|| runtimeActionKey !== null && runtimeActionKey !== `${summary.key}:start`
}
onClick={() =>
controlWorkspaceRuntime.mutate({
key: summary.key,
kind: summary.kind,
workspaceId: summary.workspaceId,
action: "start",
})
}
>
{runtimeActionKey === `${summary.key}:start` ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Start
</Button>
<Button
variant="outline"
size="sm"
disabled={controlWorkspaceRuntime.isPending || summary.serviceCount === 0}
onClick={() =>
controlWorkspaceRuntime.mutate({
key: summary.key,
kind: summary.kind,
workspaceId: summary.workspaceId,
action: "stop",
})
}
>
Stop
</Button>
</div>
{summary.kind === "execution_workspace" && summary.executionWorkspaceId && summary.executionWorkspaceStatus ? (
<Button
variant="outline"

View file

@ -25,6 +25,7 @@ type WorkspaceFormState = {
remoteProvider: string;
remoteWorkspaceRef: string;
sharedWorkspaceKey: string;
runtimeConfig: string;
};
type ProjectWorkspaceSourceType = ProjectWorkspace["sourceType"];
@ -60,6 +61,11 @@ function readText(value: string | null | undefined) {
return value ?? "";
}
function formatJson(value: Record<string, unknown> | null | undefined) {
if (!value || Object.keys(value).length === 0) return "";
return JSON.stringify(value, null, 2);
}
function formStateFromWorkspace(workspace: ProjectWorkspace): WorkspaceFormState {
return {
name: workspace.name,
@ -74,6 +80,7 @@ function formStateFromWorkspace(workspace: ProjectWorkspace): WorkspaceFormState
remoteProvider: readText(workspace.remoteProvider),
remoteWorkspaceRef: readText(workspace.remoteWorkspaceRef),
sharedWorkspaceKey: readText(workspace.sharedWorkspaceKey),
runtimeConfig: formatJson(workspace.runtimeConfig?.workspaceRuntime),
};
}
@ -82,6 +89,27 @@ function normalizeText(value: string) {
return trimmed.length > 0 ? trimmed : null;
}
function parseRuntimeConfigJson(value: string) {
const trimmed = value.trim();
if (!trimmed) return { ok: true as const, value: null as Record<string, unknown> | null };
try {
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {
ok: false as const,
error: "Runtime services JSON must be a JSON object.",
};
}
return { ok: true as const, value: parsed as Record<string, unknown> };
} catch (error) {
return {
ok: false as const,
error: error instanceof Error ? error.message : "Invalid JSON.",
};
}
}
function buildWorkspacePatch(initialState: WorkspaceFormState, nextState: WorkspaceFormState) {
const patch: Record<string, unknown> = {};
const maybeAssign = (key: keyof WorkspaceFormState, transform?: (value: string) => unknown) => {
@ -103,6 +131,13 @@ function buildWorkspacePatch(initialState: WorkspaceFormState, nextState: Worksp
maybeAssign("remoteProvider", normalizeText);
maybeAssign("remoteWorkspaceRef", normalizeText);
maybeAssign("sharedWorkspaceKey", normalizeText);
if (initialState.runtimeConfig !== nextState.runtimeConfig) {
const parsed = parseRuntimeConfigJson(nextState.runtimeConfig);
if (!parsed.ok) throw new Error(parsed.error);
patch.runtimeConfig = {
workspaceRuntime: parsed.value,
};
}
return patch;
}
@ -132,6 +167,11 @@ function validateWorkspaceForm(form: WorkspaceFormState) {
}
}
const runtimeConfig = parseRuntimeConfigJson(form.runtimeConfig);
if (!runtimeConfig.ok) {
return runtimeConfig.error;
}
return null;
}
@ -176,6 +216,7 @@ export function ProjectWorkspaceDetail() {
const queryClient = useQueryClient();
const [form, setForm] = useState<WorkspaceFormState | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
const routeProjectRef = projectId ?? "";
const routeWorkspaceId = workspaceId ?? "";
@ -261,6 +302,26 @@ export function ProjectWorkspaceDetail() {
},
});
const controlRuntimeServices = useMutation({
mutationFn: (action: "start" | "stop" | "restart") =>
projectsApi.controlWorkspaceRuntimeServices(project!.id, routeWorkspaceId, action, lookupCompanyId),
onSuccess: (result, action) => {
invalidateProject();
setErrorMessage(null);
setRuntimeActionMessage(
action === "stop"
? "Runtime services stopped."
: action === "restart"
? "Runtime services restarted."
: "Runtime services started.",
);
},
onError: (error) => {
setRuntimeActionMessage(null);
setErrorMessage(error instanceof Error ? error.message : "Failed to control runtime services.");
},
});
if (projectQuery.isLoading) return <p className="text-sm text-muted-foreground">Loading workspace</p>;
if (projectQuery.error) {
return (
@ -311,7 +372,8 @@ export function ProjectWorkspaceDetail() {
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Configure the concrete workspace Paperclip attaches to this project. These values drive per-workspace
checkout behavior and let you override setup or cleanup commands when one workspace needs special handling.
checkout behavior, default runtime services for child execution workspaces, and let you override setup
or cleanup commands when one workspace needs special handling.
</p>
</div>
{!workspace.isPrimary ? (
@ -464,6 +526,15 @@ 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-36 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>
</div>
<div className="mt-5 flex flex-wrap items-center gap-3">
@ -482,6 +553,7 @@ export function ProjectWorkspaceDetail() {
Reset
</Button>
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
{!errorMessage && runtimeActionMessage ? <p className="text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
{!errorMessage && !isDirty ? <p className="text-sm text-muted-foreground">No unsaved changes.</p> : null}
</div>
</div>
@ -518,9 +590,41 @@ export function ProjectWorkspaceDetail() {
</div>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="space-y-1">
<div className="flex items-start justify-between gap-3">
<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>
<p className="text-sm text-muted-foreground">
Shared services for this project workspace. Execution workspaces inherit this config unless they override it.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={controlRuntimeServices.isPending || !workspace.runtimeConfig?.workspaceRuntime || !workspace.cwd}
onClick={() => controlRuntimeServices.mutate("start")}
>
{controlRuntimeServices.isPending ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Start
</Button>
<Button
variant="outline"
size="sm"
disabled={controlRuntimeServices.isPending || !workspace.cwd}
onClick={() => controlRuntimeServices.mutate("restart")}
>
Restart
</Button>
<Button
variant="outline"
size="sm"
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0}
onClick={() => controlRuntimeServices.mutate("stop")}
>
Stop
</Button>
</div>
</div>
<Separator className="my-4" />
{workspace.runtimeServices && workspace.runtimeServices.length > 0 ? (
@ -530,25 +634,31 @@ export function ProjectWorkspaceDetail() {
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="text-sm font-medium">{service.serviceName}</div>
<div className="text-xs text-muted-foreground">
<div className="space-y-1 text-xs text-muted-foreground">
{service.url ? (
<a href={service.url} target="_blank" rel="noreferrer" className="hover:underline">
<a href={service.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
{service.url}
<ExternalLink className="h-3 w-3" />
</a>
) : (
service.command ?? "No command recorded"
)}
) : null}
{service.port ? <div>Port {service.port}</div> : null}
<div>{service.command ?? "No command recorded"}</div>
{service.cwd ? <div className="break-all font-mono">{service.cwd}</div> : null}
</div>
</div>
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
{service.status}
{service.status} · {service.healthStatus}
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No runtime services are attached to this workspace.</p>
<p className="text-sm text-muted-foreground">
{workspace.runtimeConfig?.workspaceRuntime
? "No runtime services are currently running for this workspace."
: "No runtime-service default is configured for this workspace yet."}
</p>
)}
</div>
</div>