From 99ed10098157e5c1bf58aa0101ff955d76c1c98f Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 12 Apr 2026 11:11:23 -0500 Subject: [PATCH] Fix workspace runtime control requests --- ui/src/api/execution-workspaces.ts | 5 +- ui/src/api/projects.ts | 5 +- ui/src/api/workspace-runtime-control.test.ts | 28 +++++ ui/src/api/workspace-runtime-control.ts | 11 ++ .../WorkspaceRuntimeControls.test.tsx | 114 ++++++++++++++++++ .../components/WorkspaceRuntimeControls.tsx | 16 ++- ui/src/pages/ExecutionWorkspaceDetail.tsx | 16 ++- 7 files changed, 183 insertions(+), 12 deletions(-) create mode 100644 ui/src/api/workspace-runtime-control.test.ts create mode 100644 ui/src/api/workspace-runtime-control.ts diff --git a/ui/src/api/execution-workspaces.ts b/ui/src/api/execution-workspaces.ts index 7318d8eb90e..2c10a4dc44a 100644 --- a/ui/src/api/execution-workspaces.ts +++ b/ui/src/api/execution-workspaces.ts @@ -5,6 +5,7 @@ import type { WorkspaceRuntimeControlTarget, } from "@paperclipai/shared"; import { api } from "./client"; +import { sanitizeWorkspaceRuntimeControlTarget } from "./workspace-runtime-control"; export const executionWorkspacesApi = { list: ( @@ -38,7 +39,7 @@ export const executionWorkspacesApi = { ) => api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>( `/execution-workspaces/${id}/runtime-services/${action}`, - target, + sanitizeWorkspaceRuntimeControlTarget(target), ), controlRuntimeCommands: ( id: string, @@ -47,7 +48,7 @@ export const executionWorkspacesApi = { ) => api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>( `/execution-workspaces/${id}/runtime-commands/${action}`, - target, + sanitizeWorkspaceRuntimeControlTarget(target), ), update: (id: string, data: Record) => api.patch(`/execution-workspaces/${id}`, data), }; diff --git a/ui/src/api/projects.ts b/ui/src/api/projects.ts index fb40c9e144b..e975f829c11 100644 --- a/ui/src/api/projects.ts +++ b/ui/src/api/projects.ts @@ -5,6 +5,7 @@ import type { WorkspaceRuntimeControlTarget, } from "@paperclipai/shared"; import { api } from "./client"; +import { sanitizeWorkspaceRuntimeControlTarget } from "./workspace-runtime-control"; function withCompanyScope(path: string, companyId?: string) { if (!companyId) return path; @@ -41,7 +42,7 @@ export const projectsApi = { ) => api.post<{ workspace: ProjectWorkspace; operation: WorkspaceOperation }>( projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}/runtime-services/${action}`), - target, + sanitizeWorkspaceRuntimeControlTarget(target), ), controlWorkspaceCommands: ( projectId: string, @@ -52,7 +53,7 @@ export const projectsApi = { ) => api.post<{ workspace: ProjectWorkspace; operation: WorkspaceOperation }>( projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}/runtime-commands/${action}`), - target, + sanitizeWorkspaceRuntimeControlTarget(target), ), removeWorkspace: (projectId: string, workspaceId: string, companyId?: string) => api.delete(projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`)), diff --git a/ui/src/api/workspace-runtime-control.test.ts b/ui/src/api/workspace-runtime-control.test.ts new file mode 100644 index 00000000000..ac7b28585cb --- /dev/null +++ b/ui/src/api/workspace-runtime-control.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeWorkspaceRuntimeControlTarget } from "./workspace-runtime-control"; + +describe("sanitizeWorkspaceRuntimeControlTarget", () => { + it("drops unexpected keys while preserving the selected runtime target", () => { + const sanitized = sanitizeWorkspaceRuntimeControlTarget({ + workspaceCommandId: "web", + runtimeServiceId: "service-1", + serviceIndex: 2, + ...( { action: "start" } as Record ), + }); + + expect(sanitized).toEqual({ + workspaceCommandId: "web", + runtimeServiceId: "service-1", + serviceIndex: 2, + }); + expect("action" in sanitized).toBe(false); + }); + + it("normalizes an omitted target to nullable fields", () => { + expect(sanitizeWorkspaceRuntimeControlTarget()).toEqual({ + workspaceCommandId: null, + runtimeServiceId: null, + serviceIndex: null, + }); + }); +}); diff --git a/ui/src/api/workspace-runtime-control.ts b/ui/src/api/workspace-runtime-control.ts new file mode 100644 index 00000000000..d99d6f1c97d --- /dev/null +++ b/ui/src/api/workspace-runtime-control.ts @@ -0,0 +1,11 @@ +import type { WorkspaceRuntimeControlTarget } from "@paperclipai/shared"; + +export function sanitizeWorkspaceRuntimeControlTarget( + target: WorkspaceRuntimeControlTarget = {}, +): WorkspaceRuntimeControlTarget { + return { + workspaceCommandId: target.workspaceCommandId ?? null, + runtimeServiceId: target.runtimeServiceId ?? null, + serviceIndex: target.serviceIndex ?? null, + }; +} diff --git a/ui/src/components/WorkspaceRuntimeControls.test.tsx b/ui/src/components/WorkspaceRuntimeControls.test.tsx index af160628ae2..7f8b0929799 100644 --- a/ui/src/components/WorkspaceRuntimeControls.test.tsx +++ b/ui/src/components/WorkspaceRuntimeControls.test.tsx @@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client"; import type { WorkspaceRuntimeService } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + buildWorkspaceRuntimeControlItems, buildWorkspaceRuntimeControlSections, WorkspaceRuntimeControls, } from "./WorkspaceRuntimeControls"; @@ -77,6 +78,32 @@ describe("buildWorkspaceRuntimeControlSections", () => { }); }); +describe("buildWorkspaceRuntimeControlItems", () => { + it("keeps the legacy flat export shape for stale importers", () => { + const items = buildWorkspaceRuntimeControlItems({ + 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(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + title: "web", + status: "running", + statusLabel: "running", + runtimeServiceId: "service-web", + }); + }); +}); + describe("WorkspaceRuntimeControls", () => { let container: HTMLDivElement; @@ -152,4 +179,91 @@ describe("WorkspaceRuntimeControls", () => { act(() => root.unmount()); }); + + it("hides the disabled hint once services can already run", () => { + const sections = buildWorkspaceRuntimeControlSections({ + runtimeConfig: { + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev" }, + ], + }, + runtimeServices: [ + createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }), + ], + canStartServices: true, + }); + + const root = createRoot(container); + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).not.toContain("Add runtime settings first."); + + act(() => root.unmount()); + }); + + it("hides the health badge for stopped services", () => { + const sections = buildWorkspaceRuntimeControlSections({ + runtimeConfig: { + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev" }, + ], + }, + runtimeServices: [ + createRuntimeService({ id: "service-web", serviceName: "web", status: "stopped", healthStatus: "unknown" }), + ], + canStartServices: true, + }); + + const root = createRoot(container); + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).not.toContain("unknown"); + + act(() => root.unmount()); + }); + + it("accepts the legacy items prop without crashing", () => { + const items = buildWorkspaceRuntimeControlItems({ + runtimeConfig: { + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev" }, + ], + }, + runtimeServices: [], + canStartServices: false, + }); + + const root = createRoot(container); + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).toContain("Services"); + expect(container.textContent).toContain("Add runtime settings first."); + expect(Array.from(container.querySelectorAll("button")).map((button) => button.textContent?.trim())).toEqual(["Start"]); + + act(() => root.unmount()); + }); }); diff --git a/ui/src/components/WorkspaceRuntimeControls.tsx b/ui/src/components/WorkspaceRuntimeControls.tsx index d4afe9a4e43..ef75181fe80 100644 --- a/ui/src/components/WorkspaceRuntimeControls.tsx +++ b/ui/src/components/WorkspaceRuntimeControls.tsx @@ -326,9 +326,16 @@ function CommandSection({ {item.cwd ?
{item.cwd}
: null} {item.disabledReason ?
{item.disabledReason}
: null} - {item.healthStatus ? ( + {item.healthStatus && item.statusLabel !== "stopped" ? (
- + {item.healthStatus}
@@ -366,6 +373,7 @@ export function WorkspaceRuntimeControls({ const runningCount = resolvedSections.services.filter( (item) => item.statusLabel === "running" || item.statusLabel === "starting", ).length; + const visibleDisabledHint = runningCount > 0 || disabledHint === null ? null : disabledHint; return (
@@ -390,7 +398,7 @@ export function WorkspaceRuntimeControls({ : "Each command can be controlled independently."}
- {disabledHint ?

{disabledHint}

: null} + {visibleDisabledHint ?

{visibleDisabledHint}

: null} @@ -399,7 +407,7 @@ export function WorkspaceRuntimeControls({ description="Long-running commands that Paperclip can supervise for this workspace." items={resolvedSections.services} emptyMessage={resolvedServiceEmptyMessage} - disabledHint={disabledHint} + disabledHint={visibleDisabledHint} isPending={isPending} pendingRequest={pendingRequest} onAction={onAction} diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index b5748f92aa4..fb7c1808b65 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -317,6 +317,7 @@ export function ExecutionWorkspaceDetail() { const [form, setForm] = useState(null); const [closeDialogOpen, setCloseDialogOpen] = useState(false); const [errorMessage, setErrorMessage] = useState(null); + const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState(null); const [runtimeActionMessage, setRuntimeActionMessage] = useState(null); const activeTab = workspaceId ? resolveExecutionWorkspaceTab(location.pathname, workspaceId) : null; @@ -384,6 +385,7 @@ export function ExecutionWorkspaceDetail() { if (!workspace) return; setForm(formStateFromWorkspace(workspace)); setErrorMessage(null); + setRuntimeActionErrorMessage(null); }, [workspace]); useEffect(() => { @@ -428,7 +430,7 @@ export function ExecutionWorkspaceDetail() { 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); + setRuntimeActionErrorMessage(null); setRuntimeActionMessage( request.action === "run" ? "Workspace job completed." @@ -441,7 +443,7 @@ export function ExecutionWorkspaceDetail() { }, onError: (error) => { setRuntimeActionMessage(null); - setErrorMessage(error instanceof Error ? error.message : "Failed to control workspace commands."); + setRuntimeActionErrorMessage(error instanceof Error ? error.message : "Failed to control workspace commands."); }, }); @@ -555,10 +557,15 @@ export function ExecutionWorkspaceDetail() { : "No workspace command config is defined for this execution workspace yet." } 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." + disabledHint={ + canStartRuntimeServices + ? null + : "Execution workspaces need a working directory before local commands can run, and services also need runtime config." + } onAction={(request) => controlRuntimeServices.mutate(request)} /> - {!errorMessage && runtimeActionMessage ?

{runtimeActionMessage}

: null} + {runtimeActionErrorMessage ?

{runtimeActionErrorMessage}

: null} + {!runtimeActionErrorMessage && runtimeActionMessage ?

{runtimeActionMessage}

: null} handleTabChange(value as ExecutionWorkspaceTab)}> @@ -762,6 +769,7 @@ export function ExecutionWorkspaceDetail() { onClick={() => { setForm(initialState); setErrorMessage(null); + setRuntimeActionErrorMessage(null); setRuntimeActionMessage(null); }} >