[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:
Dotta
2026-04-20 10:39:37 -05:00
committed by GitHub
parent c7c1ca0c78
commit 549ef11c14
21 changed files with 449 additions and 65 deletions

View File

@@ -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") {