Files
paperclip/ui/src/components/RoutineRunVariablesDialog.test.tsx
T
Dotta 549ef11c14 [codex] Respect manual workspace runtime controls (#4125)
## Thinking Path

> - Paperclip orchestrates AI agents inside execution and project
workspaces
> - Workspace runtime services can be controlled manually by operators
and reused by agent runs
> - Manual start/stop state was not preserved consistently across
workspace policies and routine launches
> - Routine launches also needed branch/workspace variables to default
from the selected workspace context
> - This pull request makes runtime policy state explicit, preserves
manual control, and auto-fills routine branch variables from workspace
data
> - The benefit is less surprising workspace service behavior and fewer
manual inputs when running workspace-scoped routines

## What Changed

- Added runtime-state handling for manual workspace control across
execution and project workspace validators, routes, and services.
- Updated heartbeat/runtime startup behavior so manually stopped
services are respected.
- Auto-filled routine workspace branch variables from available
workspace context.
- Added focused server and UI tests for workspace runtime and routine
variable behavior.
- Removed muted gray background styling from workspace pages and cards
for a cleaner workspace UI.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm exec vitest run server/src/__tests__/routines-service.test.ts
server/src/__tests__/workspace-runtime.test.ts
ui/src/components/RoutineRunVariablesDialog.test.tsx`
- Result: 55 tests passed, 21 skipped. The embedded Postgres routines
tests skipped on this host with the existing PGlite/Postgres init
warning; workspace-runtime and UI tests passed.

## Risks

- Medium risk: this touches runtime service start/stop policy and
heartbeat launch behavior.
- The focused tests cover manual runtime state, routine variables, and
workspace runtime reuse paths.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex coding agent based on GPT-5, tool-enabled local shell and
GitHub workflow, exact runtime context window not exposed in this
session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots, or documented why targeted component/service verification
is sufficient here
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-20 10:39:37 -05:00

268 lines
7.6 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent, Project } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog";
let issueWorkspaceDraftCalls = 0;
let issueWorkspaceDraft = {
executionWorkspaceId: null as string | null,
executionWorkspacePreference: "shared_workspace",
executionWorkspaceSettings: { mode: "shared_workspace" },
};
let issueWorkspaceBranchName: string | null = null;
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: {
getExperimental: vi.fn(async () => ({ enableIsolatedWorkspaces: true })),
},
}));
vi.mock("./IssueWorkspaceCard", async () => {
const React = await import("react");
return {
IssueWorkspaceCard: ({
onDraftChange,
}: {
onDraftChange?: (
data: Record<string, unknown>,
meta: { canSave: boolean; workspaceBranchName?: string | null },
) => void;
}) => {
React.useEffect(() => {
issueWorkspaceDraftCalls += 1;
if (issueWorkspaceDraftCalls > 20) {
throw new Error("IssueWorkspaceCard onDraftChange looped");
}
onDraftChange?.(issueWorkspaceDraft, {
canSave: true,
workspaceBranchName: issueWorkspaceBranchName,
});
}, [onDraftChange]);
return <div data-testid="workspace-card">Workspace card</div>;
},
};
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function createProject(): Project {
return {
id: "project-1",
companyId: "company-1",
urlKey: "workspace-project",
goalId: null,
goalIds: [],
goals: [],
name: "Workspace project",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#22c55e",
env: null,
pauseReason: null,
pausedAt: null,
archivedAt: null,
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
allowIssueOverride: true,
},
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/paperclip/project-1",
effectiveLocalFolder: "/tmp/paperclip/project-1",
origin: "managed_checkout",
},
workspaces: [],
primaryWorkspace: null,
createdAt: new Date("2026-04-02T00:00:00.000Z"),
updatedAt: new Date("2026-04-02T00:00:00.000Z"),
};
}
function createAgent(): Agent {
return {
id: "agent-1",
companyId: "company-1",
name: "Routine Agent",
role: "engineer",
title: null,
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
lastHeartbeatAt: null,
icon: "code",
metadata: null,
createdAt: new Date("2026-04-02T00:00:00.000Z"),
updatedAt: new Date("2026-04-02T00:00:00.000Z"),
urlKey: "routine-agent",
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
};
}
describe("RoutineRunVariablesDialog", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
issueWorkspaceDraftCalls = 0;
issueWorkspaceDraft = {
executionWorkspaceId: null,
executionWorkspacePreference: "shared_workspace",
executionWorkspaceSettings: { mode: "shared_workspace" },
};
issueWorkspaceBranchName = null;
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
});
it("does not loop when the workspace card reports the same draft repeatedly", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<RoutineRunVariablesDialog
open
onOpenChange={() => {}}
companyId="company-1"
projects={[createProject()]}
agents={[createAgent()]}
defaultProjectId="project-1"
defaultAssigneeAgentId="agent-1"
variables={[]}
isPending={false}
onSubmit={() => {}}
/>
</QueryClientProvider>,
);
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(issueWorkspaceDraftCalls).toBeLessThanOrEqual(2);
expect(document.body.textContent).toContain("Run routine");
expect(document.body.textContent).not.toContain("Search agents...");
expect(document.body.textContent).not.toContain("Search projects...");
await act(async () => {
root.unmount();
});
});
it("renders workspaceBranch as a read-only selected workspace value", async () => {
issueWorkspaceDraft = {
executionWorkspaceId: "workspace-1",
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "isolated_workspace" },
};
issueWorkspaceBranchName = "pap-1634-routine-branch";
const onSubmit = vi.fn();
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<RoutineRunVariablesDialog
open
onOpenChange={() => {}}
companyId="company-1"
projects={[createProject()]}
agents={[createAgent()]}
defaultProjectId="project-1"
defaultAssigneeAgentId="agent-1"
variables={[
{
name: "workspaceBranch",
label: null,
type: "text",
defaultValue: null,
required: true,
options: [],
},
]}
isPending={false}
onSubmit={onSubmit}
/>
</QueryClientProvider>,
);
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
for (let i = 0; i < 10 && !document.querySelector('[data-testid="workspace-card"]'); i += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
const branchInput = Array.from(document.querySelectorAll("input"))
.find((input) => input.value === "pap-1634-routine-branch");
expect(branchInput?.disabled).toBe(true);
expect(document.body.textContent).not.toContain("Missing: workspaceBranch");
const runButton = Array.from(document.querySelectorAll("button"))
.find((button) => button.textContent === "Run routine");
expect(runButton).toBeTruthy();
await act(async () => {
runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onSubmit).toHaveBeenCalledWith({
variables: {
workspaceBranch: "pap-1634-routine-branch",
},
assigneeAgentId: "agent-1",
projectId: "project-1",
executionWorkspaceId: "workspace-1",
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "isolated_workspace" },
});
await act(async () => {
root.unmount();
});
});
});