mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Fix workspace runtime control requests
This commit is contained in:
1 parent
e25a58d1a0
commit
99ed100981
7 files changed
+183
-12
No files matched your search
@@ -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),
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user