[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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 449 additions and 65 deletions

View file

@ -4,6 +4,7 @@ import type { Db } from "@paperclipai/db";
import {
agents,
companySecrets,
executionWorkspaces,
goals,
heartbeatRuns,
issues,
@ -27,7 +28,9 @@ import type {
UpdateRoutineTrigger,
} from "@paperclipai/shared";
import {
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
getBuiltinRoutineVariableValues,
extractRoutineVariableNames,
interpolateRoutineTemplate,
stringifyRoutineVariableValue,
syncRoutineVariablesWithTemplate,
@ -269,15 +272,23 @@ function resolveRoutineVariableValues(
source: "schedule" | "manual" | "api" | "webhook";
payload?: Record<string, unknown> | null;
variables?: Record<string, unknown> | null;
automaticVariables?: Record<string, string | number | boolean>;
},
) {
if (variables.length === 0) return {} as Record<string, string | number | boolean>;
const provided = collectProvidedRoutineVariables(input.source, input.payload, input.variables);
const automaticVariables = input.automaticVariables ?? {};
const resolved: Record<string, string | number | boolean> = {};
const missing: string[] = [];
for (const variable of variables) {
const candidate = provided[variable.name] !== undefined ? provided[variable.name] : variable.defaultValue;
// Workspace-derived automatic values are authoritative for variables that
// Paperclip manages from execution context, so callers cannot override them.
const candidate = automaticVariables[variable.name] !== undefined
? automaticVariables[variable.name]
: provided[variable.name] !== undefined
? provided[variable.name]
: variable.defaultValue;
const normalized = normalizeRoutineVariableValue(variable, candidate);
if (normalized == null || (typeof normalized === "string" && normalized.trim().length === 0)) {
if (variable.required) missing.push(variable.name);
@ -309,6 +320,11 @@ function mergeRoutineRunPayload(
};
}
function routineUsesWorkspaceBranch(routine: typeof routines.$inferSelect) {
return (routine.variables ?? []).some((variable) => variable.name === WORKSPACE_BRANCH_ROUTINE_VARIABLE)
|| extractRoutineVariableNames([routine.title, routine.description]).includes(WORKSPACE_BRANCH_ROUTINE_VARIABLE);
}
export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeupDeps } = {}) {
const issueSvc = issueService(db);
const secretsSvc = secretService(db);
@ -701,11 +717,34 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
if (!assigneeAgentId) {
throw unprocessable("Default agent required");
}
const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], input);
const allVariables = { ...getBuiltinRoutineVariableValues(), ...resolvedVariables };
const automaticVariables: Record<string, string | number | boolean> = {};
if (input.executionWorkspaceId && routineUsesWorkspaceBranch(input.routine)) {
const workspace = await db
.select({
branchName: executionWorkspaces.branchName,
mode: executionWorkspaces.mode,
})
.from(executionWorkspaces)
.where(
and(
eq(executionWorkspaces.id, input.executionWorkspaceId),
eq(executionWorkspaces.companyId, input.routine.companyId),
),
)
.then((rows) => rows[0] ?? null);
const branchName = workspace?.branchName?.trim();
if (workspace && workspace.mode !== "shared_workspace" && branchName) {
automaticVariables[WORKSPACE_BRANCH_ROUTINE_VARIABLE] = branchName;
}
}
const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], {
...input,
automaticVariables,
});
const allVariables = { ...getBuiltinRoutineVariableValues(), ...automaticVariables, ...resolvedVariables };
const title = interpolateRoutineTemplate(input.routine.title, allVariables) ?? input.routine.title;
const description = interpolateRoutineTemplate(input.routine.description, allVariables);
const triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables);
const triggerPayload = mergeRoutineRunPayload(input.payload, { ...automaticVariables, ...resolvedVariables });
const run = await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
await tx.execute(