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

@ -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>