mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
[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>
This commit is contained in:
parent
c7c1ca0c78
commit
549ef11c14
21 changed files with 449 additions and 65 deletions
|
|
@ -98,7 +98,7 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
</DialogHeader>
|
||||
|
||||
{readinessQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-background px-4 py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Checking whether this workspace is safe to close...
|
||||
</div>
|
||||
|
|
@ -174,7 +174,7 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
{readiness.git ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Git status</h3>
|
||||
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="rounded-xl border border-border bg-background px-4 py-3 text-sm">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Branch</div>
|
||||
|
|
@ -212,7 +212,7 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
<h3 className="text-sm font-medium">Other linked issues</h3>
|
||||
<div className="space-y-2">
|
||||
{otherLinkedIssues.map((issue) => (
|
||||
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div key={issue.id} className="rounded-xl border border-border bg-background px-4 py-3 text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
||||
{issue.identifier ?? issue.id} · {issue.title}
|
||||
|
|
@ -230,7 +230,7 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
<h3 className="text-sm font-medium">Attached runtime services</h3>
|
||||
<div className="space-y-2">
|
||||
{readiness.runtimeServices.map((service) => (
|
||||
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div key={service.id} className="rounded-xl border border-border bg-background px-4 py-3 text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-medium">{service.serviceName}</span>
|
||||
<span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</span>
|
||||
|
|
@ -248,7 +248,7 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
<h3 className="text-sm font-medium">Cleanup actions</h3>
|
||||
<div className="space-y-2">
|
||||
{readiness.plannedActions.map((action, index) => (
|
||||
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-background px-4 py-3 text-sm">
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<div className="mt-1 break-words text-muted-foreground">{action.description}</div>
|
||||
{action.command ? (
|
||||
|
|
@ -269,7 +269,7 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
) : null}
|
||||
|
||||
{currentStatus === "archived" ? (
|
||||
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||||
<div className="rounded-xl border border-border bg-background px-4 py-3 text-sm text-muted-foreground">
|
||||
This workspace is already archived.
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ interface IssueWorkspaceCardProps {
|
|||
onUpdate: (data: Record<string, unknown>) => void;
|
||||
initialEditing?: boolean;
|
||||
livePreview?: boolean;
|
||||
onDraftChange?: (data: Record<string, unknown>, meta: { canSave: boolean }) => void;
|
||||
onDraftChange?: (data: Record<string, unknown>, meta: { canSave: boolean; workspaceBranchName?: string | null }) => void;
|
||||
}
|
||||
|
||||
export function IssueWorkspaceCard({
|
||||
|
|
@ -298,6 +298,10 @@ export function IssueWorkspaceCard({
|
|||
});
|
||||
|
||||
const canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0;
|
||||
const draftWorkspaceBranchName =
|
||||
draftSelection === "reuse_existing" && configuredReusableWorkspace?.mode !== "shared_workspace"
|
||||
? configuredReusableWorkspace?.branchName ?? null
|
||||
: null;
|
||||
|
||||
const buildWorkspaceDraftUpdate = useCallback(() => ({
|
||||
executionWorkspacePreference: draftSelection,
|
||||
|
|
@ -316,8 +320,11 @@ export function IssueWorkspaceCard({
|
|||
|
||||
useEffect(() => {
|
||||
if (!onDraftChange) return;
|
||||
onDraftChange(buildWorkspaceDraftUpdate(), { canSave: canSaveWorkspaceConfig });
|
||||
}, [buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, onDraftChange]);
|
||||
onDraftChange(buildWorkspaceDraftUpdate(), {
|
||||
canSave: canSaveWorkspaceConfig,
|
||||
workspaceBranchName: draftWorkspaceBranchName,
|
||||
});
|
||||
}, [buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, draftWorkspaceBranchName, onDraftChange]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!canSaveWorkspaceConfig) return;
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export function ProjectWorkspaceSummaryCard({
|
|||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full border border-border bg-muted/25 px-2.5 py-1 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
<span className="inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{workspaceKindLabel(summary.kind)}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full border border-border/70 bg-background px-2.5 py-1 text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ 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: {
|
||||
|
|
@ -22,18 +28,20 @@ vi.mock("./IssueWorkspaceCard", async () => {
|
|||
IssueWorkspaceCard: ({
|
||||
onDraftChange,
|
||||
}: {
|
||||
onDraftChange?: (data: Record<string, unknown>, meta: { canSave: boolean }) => void;
|
||||
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?.({
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: "shared_workspace",
|
||||
executionWorkspaceSettings: { mode: "shared_workspace" },
|
||||
}, { canSave: true });
|
||||
onDraftChange?.(issueWorkspaceDraft, {
|
||||
canSave: true,
|
||||
workspaceBranchName: issueWorkspaceBranchName,
|
||||
});
|
||||
}, [onDraftChange]);
|
||||
|
||||
return <div data-testid="workspace-card">Workspace card</div>;
|
||||
|
|
@ -119,6 +127,12 @@ describe("RoutineRunVariablesDialog", () => {
|
|||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
issueWorkspaceDraftCalls = 0;
|
||||
issueWorkspaceDraft = {
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: "shared_workspace",
|
||||
executionWorkspaceSettings: { mode: "shared_workspace" },
|
||||
};
|
||||
issueWorkspaceBranchName = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -155,6 +169,7 @@ describe("RoutineRunVariablesDialog", () => {
|
|||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(issueWorkspaceDraftCalls).toBeLessThanOrEqual(2);
|
||||
|
|
@ -166,4 +181,87 @@ describe("RoutineRunVariablesDialog", () => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { Agent, IssueExecutionWorkspaceSettings, Project, RoutineVariable } from "@paperclipai/shared";
|
||||
import {
|
||||
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
|
||||
type Agent,
|
||||
type IssueExecutionWorkspaceSettings,
|
||||
type Project,
|
||||
type RoutineVariable,
|
||||
} from "@paperclipai/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
|
@ -189,6 +195,7 @@ export function RoutineRunVariablesDialog({
|
|||
: null;
|
||||
const [workspaceConfig, setWorkspaceConfig] = useState(() => buildInitialWorkspaceConfig(selectedProject));
|
||||
const [workspaceConfigValid, setWorkspaceConfigValid] = useState(true);
|
||||
const [workspaceBranchName, setWorkspaceBranchName] = useState<string | null>(null);
|
||||
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
|
|
@ -208,15 +215,27 @@ export function RoutineRunVariablesDialog({
|
|||
setSelection(nextSelection);
|
||||
setWorkspaceConfig(buildInitialWorkspaceConfig(projects.find((project) => project.id === nextSelection.projectId) ?? null));
|
||||
setWorkspaceConfigValid(true);
|
||||
setWorkspaceBranchName(null);
|
||||
}, [defaultAssigneeAgentId, defaultProjectId, open, projects, variables]);
|
||||
|
||||
const workspaceBranchAutoValue = workspaceSelectionEnabled && workspaceBranchName
|
||||
? workspaceBranchName
|
||||
: null;
|
||||
|
||||
const isAutoWorkspaceBranchVariable = useCallback(
|
||||
(variable: RoutineVariable) =>
|
||||
variable.name === WORKSPACE_BRANCH_ROUTINE_VARIABLE && Boolean(workspaceBranchAutoValue),
|
||||
[workspaceBranchAutoValue],
|
||||
);
|
||||
|
||||
const missingRequired = useMemo(
|
||||
() =>
|
||||
variables
|
||||
.filter((variable) => variable.required)
|
||||
.filter((variable) => !isAutoWorkspaceBranchVariable(variable))
|
||||
.filter((variable) => isMissingRequiredValue(values[variable.name]))
|
||||
.map((variable) => variable.label || variable.name),
|
||||
[values, variables],
|
||||
[isAutoWorkspaceBranchVariable, values, variables],
|
||||
);
|
||||
|
||||
const workspaceIssue = useMemo(() => ({
|
||||
|
|
@ -247,10 +266,14 @@ export function RoutineRunVariablesDialog({
|
|||
|
||||
const handleWorkspaceDraftChange = useCallback((
|
||||
data: Record<string, unknown>,
|
||||
meta: { canSave: boolean },
|
||||
meta: { canSave: boolean; workspaceBranchName?: string | null },
|
||||
) => {
|
||||
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
|
||||
setWorkspaceConfigValid((current) => (current === meta.canSave ? current : meta.canSave));
|
||||
setWorkspaceBranchName((current) => {
|
||||
const next = meta.workspaceBranchName ?? null;
|
||||
return current === next ? current : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
@ -328,6 +351,7 @@ export function RoutineRunVariablesDialog({
|
|||
setSelection((current) => ({ ...current, projectId }));
|
||||
setWorkspaceConfig(buildInitialWorkspaceConfig(project));
|
||||
setWorkspaceConfigValid(true);
|
||||
setWorkspaceBranchName(null);
|
||||
}}
|
||||
renderTriggerValue={(option) =>
|
||||
option && selectedProject ? (
|
||||
|
|
@ -365,7 +389,13 @@ export function RoutineRunVariablesDialog({
|
|||
{variable.label || variable.name}
|
||||
{variable.required ? " *" : ""}
|
||||
</Label>
|
||||
{variable.type === "textarea" ? (
|
||||
{isAutoWorkspaceBranchVariable(variable) ? (
|
||||
<Input
|
||||
readOnly
|
||||
disabled
|
||||
value={workspaceBranchAutoValue ?? ""}
|
||||
/>
|
||||
) : variable.type === "textarea" ? (
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={typeof values[variable.name] === "string" ? values[variable.name] as string : ""}
|
||||
|
|
@ -450,6 +480,10 @@ export function RoutineRunVariablesDialog({
|
|||
onClick={() => {
|
||||
const nextVariables: Record<string, string | number | boolean> = {};
|
||||
for (const variable of variables) {
|
||||
if (isAutoWorkspaceBranchVariable(variable)) {
|
||||
nextVariables[variable.name] = workspaceBranchAutoValue!;
|
||||
continue;
|
||||
}
|
||||
const rawValue = values[variable.name];
|
||||
if (isMissingRequiredValue(rawValue)) continue;
|
||||
if (variable.type === "number") {
|
||||
|
|
|
|||
|
|
@ -709,7 +709,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">Runtime config</div>
|
||||
<div className="rounded-md border border-dashed border-border/70 bg-muted/30 px-4 py-3">
|
||||
<div className="rounded-md border border-dashed border-border/70 bg-background px-4 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
|
|
@ -741,7 +741,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<details className="rounded-md border border-dashed border-border/70 bg-muted/30 px-4 py-3">
|
||||
<details className="rounded-md border border-dashed border-border/70 bg-background px-4 py-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">Advanced runtime JSON</summary>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Override the inherited workspace command model only when this execution workspace truly needs different service or job behavior.
|
||||
|
|
@ -913,7 +913,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
) : workspaceOperationsQuery.data && workspaceOperationsQuery.data.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{workspaceOperationsQuery.data.map((operation) => (
|
||||
<div key={operation.id} className="rounded-md border border-border/80 bg-muted/30 px-4 py-3">
|
||||
<div key={operation.id} className="rounded-md border border-border/80 bg-background px-4 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">{operation.command ?? operation.phase}</div>
|
||||
|
|
|
|||
|
|
@ -545,7 +545,7 @@ export function ProjectWorkspaceDetail() {
|
|||
</Field>
|
||||
</div>
|
||||
|
||||
<details className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-3 py-3">
|
||||
<details className="rounded-xl border border-dashed border-border/70 bg-background px-3 py-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">Advanced runtime JSON</summary>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Paperclip derives Services and Jobs from this JSON. Prefer editing named commands first; use raw JSON for advanced lifecycle, port, readiness, or environment settings.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue