Fix workspace runtime control requests

This commit is contained in:
Dotta committed 2026-04-14 11:51:38 -05:00
1 parent e25a58d1a0
commit 99ed100981
7 files changed
+183 -12

No files matched your search

+3 -2
View File
@@ -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<string, unknown>) => api.patch<ExecutionWorkspace>(`/execution-workspaces/${id}`, data),
};
+3 -2
View File
@@ -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<ProjectWorkspace>(projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`)),
@@ -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<string, unknown> ),
});
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,
});
});
});
+11
View File
@@ -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,
};
}
@@ -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(
<WorkspaceRuntimeControls
sections={sections}
disabledHint="Add runtime settings first."
onAction={vi.fn()}
/>,
);
});
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(
<WorkspaceRuntimeControls
sections={sections}
onAction={vi.fn()}
/>,
);
});
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(
<WorkspaceRuntimeControls
items={items}
emptyMessage="No runtime services have been started yet."
disabledHint="Add runtime settings first."
onAction={vi.fn()}
/>,
);
});
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());
});
});
+12 -4
View File
@@ -326,9 +326,16 @@ function CommandSection({
{item.cwd ? <div className="break-all font-mono">{item.cwd}</div> : null}
{item.disabledReason ? <div>{item.disabledReason}</div> : null}
</div>
{item.healthStatus ? (
{item.healthStatus && item.statusLabel !== "stopped" ? (
<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">
<span className={cn(
"inline-flex items-center rounded-full border px-2.5 py-1 text-[11px]",
item.healthStatus === "healthy"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: item.healthStatus === "unhealthy"
? "border-destructive/30 bg-destructive/10 text-destructive"
: "border-border text-muted-foreground",
)}>
{item.healthStatus}
</span>
</div>
@@ -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 (
<div className={cn("space-y-4", className)}>
@@ -390,7 +398,7 @@ export function WorkspaceRuntimeControls({
: "Each command can be controlled independently."}
</span>
</div>
{disabledHint ? <p className="text-xs text-muted-foreground">{disabledHint}</p> : null}
{visibleDisabledHint ? <p className="text-xs text-muted-foreground">{visibleDisabledHint}</p> : null}
</div>
</div>
@@ -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}
+12 -4
View File
@@ -317,6 +317,7 @@ export function ExecutionWorkspaceDetail() {
const [form, setForm] = useState<WorkspaceFormState | null>(null);
const [closeDialogOpen, setCloseDialogOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState<string | null>(null);
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(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 ? <p className="mt-4 text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
{runtimeActionErrorMessage ? <p className="mt-4 text-sm text-destructive">{runtimeActionErrorMessage}</p> : null}
{!runtimeActionErrorMessage && runtimeActionMessage ? <p className="mt-4 text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
</div>
<Tabs value={activeTab ?? "configuration"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}>
@@ -762,6 +769,7 @@ export function ExecutionWorkspaceDetail() {
onClick={() => {
setForm(initialState);
setErrorMessage(null);
setRuntimeActionErrorMessage(null);
setRuntimeActionMessage(null);
}}
>