[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

@@ -68,6 +68,8 @@ export const AGENT_ROLE_LABELS: Record<AgentRole, string> = {
export const AGENT_DEFAULT_MAX_CONCURRENT_RUNS = 5; export const AGENT_DEFAULT_MAX_CONCURRENT_RUNS = 5;
export const WORKSPACE_BRANCH_ROUTINE_VARIABLE = "workspaceBranch";
export const AGENT_ICON_NAMES = [ export const AGENT_ICON_NAMES = [
"bot", "bot",
"cpu", "cpu",

View File

@@ -10,6 +10,7 @@ export {
AGENT_ROLES, AGENT_ROLES,
AGENT_ROLE_LABELS, AGENT_ROLE_LABELS,
AGENT_DEFAULT_MAX_CONCURRENT_RUNS, AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
AGENT_ICON_NAMES, AGENT_ICON_NAMES,
ISSUE_STATUSES, ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUSES, INBOX_MINE_ISSUE_STATUSES,

View File

@@ -45,7 +45,7 @@ export type ExecutionWorkspaceCloseActionKind =
| "git_branch_delete" | "git_branch_delete"
| "remove_local_directory"; | "remove_local_directory";
export type WorkspaceRuntimeDesiredState = "running" | "stopped"; export type WorkspaceRuntimeDesiredState = "running" | "stopped" | "manual";
export type WorkspaceRuntimeServiceStateMap = Record<string, WorkspaceRuntimeDesiredState>; export type WorkspaceRuntimeServiceStateMap = Record<string, WorkspaceRuntimeDesiredState>;
export type WorkspaceCommandKind = "service" | "job"; export type WorkspaceCommandKind = "service" | "job";

View File

@@ -13,8 +13,8 @@ export const executionWorkspaceConfigSchema = z.object({
teardownCommand: z.string().optional().nullable(), teardownCommand: z.string().optional().nullable(),
cleanupCommand: z.string().optional().nullable(), cleanupCommand: z.string().optional().nullable(),
workspaceRuntime: z.record(z.unknown()).optional().nullable(), workspaceRuntime: z.record(z.unknown()).optional().nullable(),
desiredState: z.enum(["running", "stopped"]).optional().nullable(), desiredState: z.enum(["running", "stopped", "manual"]).optional().nullable(),
serviceStates: z.record(z.enum(["running", "stopped"])).optional().nullable(), serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(),
}).strict(); }).strict();
export const workspaceRuntimeControlTargetSchema = z.object({ export const workspaceRuntimeControlTargetSchema = z.object({

View File

@@ -30,8 +30,8 @@ export const projectExecutionWorkspacePolicySchema = z
export const projectWorkspaceRuntimeConfigSchema = z.object({ export const projectWorkspaceRuntimeConfigSchema = z.object({
workspaceRuntime: z.record(z.unknown()).optional().nullable(), workspaceRuntime: z.record(z.unknown()).optional().nullable(),
desiredState: z.enum(["running", "stopped"]).optional().nullable(), desiredState: z.enum(["running", "stopped", "manual"]).optional().nullable(),
serviceStates: z.record(z.enum(["running", "stopped"])).optional().nullable(), serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(),
}).strict(); }).strict();
const projectWorkspaceSourceTypeSchema = z.enum(["local_path", "git_repo", "remote_managed", "non_git_path"]); const projectWorkspaceSourceTypeSchema = z.enum(["local_path", "git_repo", "remote_managed", "non_git_path"]);

View File

@@ -461,6 +461,90 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
}); });
}); });
it("auto-populates workspaceBranch from a reused isolated workspace", async () => {
const { companyId, agentId, projectId, svc } = await seedFixture();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db
.update(projects)
.set({
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
defaultProjectWorkspaceId: projectWorkspaceId,
},
})
.where(eq(projects.id, projectId));
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
sharedWorkspaceKey: "routine-primary",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Routine worktree",
status: "active",
providerType: "git_worktree",
branchName: "pap-1634-routine-branch",
});
const branchRoutine = await svc.create(
companyId,
{
projectId,
goalId: null,
parentIssueId: null,
title: "Review {{workspaceBranch}}",
description: "Use branch {{workspaceBranch}}",
assigneeAgentId: agentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [
{ name: "workspaceBranch", label: null, type: "text", defaultValue: null, required: true, options: [] },
],
},
{},
);
const run = await svc.runRoutine(branchRoutine.id, {
source: "manual",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "isolated_workspace" },
});
const storedIssue = await db
.select({ title: issues.title, description: issues.description })
.from(issues)
.where(eq(issues.id, run.linkedIssueId!))
.then((rows) => rows[0] ?? null);
const storedRun = await db
.select({ triggerPayload: routineRuns.triggerPayload })
.from(routineRuns)
.where(eq(routineRuns.id, run.id))
.then((rows) => rows[0] ?? null);
expect(storedIssue?.title).toBe("Review pap-1634-routine-branch");
expect(storedIssue?.description).toBe("Use branch pap-1634-routine-branch");
expect(storedRun?.triggerPayload).toEqual({
variables: {
workspaceBranch: "pap-1634-routine-branch",
},
});
});
it("runs draft routines with one-off agent and project overrides", async () => { it("runs draft routines with one-off agent and project overrides", async () => {
const { companyId, agentId, projectId, svc } = await seedFixture(); const { companyId, agentId, projectId, svc } = await seedFixture();
const draftRoutine = await svc.create( const draftRoutine = await svc.create(

View File

@@ -2035,6 +2035,37 @@ describe("realizeExecutionWorkspace", () => {
}); });
describe("ensureRuntimeServicesForRun", () => { describe("ensureRuntimeServicesForRun", () => {
it("leaves manual runtime services untouched during agent runs", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-manual-"));
const workspace = buildWorkspace(workspaceRoot);
const services = await ensureRuntimeServicesForRun({
runId: "run-manual",
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
issue: null,
workspace,
config: {
desiredState: "manual",
workspaceRuntime: {
services: [
{
name: "web",
command: "node -e \"throw new Error('should not start')\"",
port: { type: "auto" },
},
],
},
},
adapterEnv: {},
});
expect(services).toEqual([]);
});
it("reuses shared runtime services across runs and starts a new service after release", async () => { it("reuses shared runtime services across runs and starts a new service after release", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-workspace-")); const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-workspace-"));
const workspace = buildWorkspace(workspaceRoot); const workspace = buildWorkspace(workspaceRoot);
@@ -2604,6 +2635,41 @@ describe("buildWorkspaceRuntimeDesiredStatePatch", () => {
}, },
}); });
}); });
it("preserves manual service state when manually starting or stopping services", () => {
const baseInput = {
config: {
workspaceRuntime: {
services: [
{ name: "web", command: "pnpm dev" },
],
},
},
currentDesiredState: "manual" as const,
currentServiceStates: null,
serviceIndex: 0,
};
expect(buildWorkspaceRuntimeDesiredStatePatch({
...baseInput,
action: "start",
})).toEqual({
desiredState: "manual",
serviceStates: {
"0": "manual",
},
});
expect(buildWorkspaceRuntimeDesiredStatePatch({
...baseInput,
action: "stop",
})).toEqual({
desiredState: "manual",
serviceStates: {
"0": "manual",
},
});
});
}); });
describe("resolveWorkspaceRuntimeReadinessTimeoutSec", () => { describe("resolveWorkspaceRuntimeReadinessTimeoutSec", () => {

View File

@@ -8,6 +8,7 @@ import {
updateExecutionWorkspaceSchema, updateExecutionWorkspaceSchema,
workspaceRuntimeControlTargetSchema, workspaceRuntimeControlTargetSchema,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
import { validate } from "../middleware/validate.js"; import { validate } from "../middleware/validate.js";
import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js"; import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js";
import { mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig } from "../services/execution-workspaces.js"; import { mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig } from "../services/execution-workspaces.js";
@@ -357,14 +358,14 @@ export function executionWorkspaceRoutes(db: Db) {
runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (existing.runtimeServices?.length ?? 1) - 1) : 0; runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (existing.runtimeServices?.length ?? 1) - 1) : 0;
} }
const currentDesiredState: "running" | "stopped" = const currentDesiredState: WorkspaceRuntimeDesiredState =
existing.config?.desiredState existing.config?.desiredState
?? ((existing.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running") ?? ((existing.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running")
? "running" ? "running"
: "stopped"); : "stopped");
const nextRuntimeState: { const nextRuntimeState: {
desiredState: "running" | "stopped"; desiredState: WorkspaceRuntimeDesiredState;
serviceStates: Record<string, "running" | "stopped"> | null | undefined; serviceStates: WorkspaceRuntimeServiceStateMap | null | undefined;
} = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null) } = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null)
? { ? {
desiredState: currentDesiredState, desiredState: currentDesiredState,

View File

@@ -10,6 +10,7 @@ import {
updateProjectWorkspaceSchema, updateProjectWorkspaceSchema,
workspaceRuntimeControlTargetSchema, workspaceRuntimeControlTargetSchema,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
import { trackProjectCreated } from "@paperclipai/shared/telemetry"; import { trackProjectCreated } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js"; import { validate } from "../middleware/validate.js";
import { projectService, logActivity, secretService, workspaceOperationService } from "../services/index.js"; import { projectService, logActivity, secretService, workspaceOperationService } from "../services/index.js";
@@ -488,14 +489,14 @@ export function projectRoutes(db: Db) {
runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (workspace.runtimeServices?.length ?? 1) - 1) : 0; runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (workspace.runtimeServices?.length ?? 1) - 1) : 0;
} }
const currentDesiredState: "running" | "stopped" = const currentDesiredState: WorkspaceRuntimeDesiredState =
workspace.runtimeConfig?.desiredState workspace.runtimeConfig?.desiredState
?? ((workspace.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running") ?? ((workspace.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running")
? "running" ? "running"
: "stopped"); : "stopped");
const nextRuntimeState: { const nextRuntimeState: {
desiredState: "running" | "stopped"; desiredState: WorkspaceRuntimeDesiredState;
serviceStates: Record<string, "running" | "stopped"> | null | undefined; serviceStates: WorkspaceRuntimeServiceStateMap | null | undefined;
} = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null) } = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null)
? { ? {
desiredState: currentDesiredState, desiredState: currentDesiredState,

View File

@@ -12,6 +12,7 @@ import type {
ExecutionWorkspaceCloseGitReadiness, ExecutionWorkspaceCloseGitReadiness,
ExecutionWorkspaceCloseReadiness, ExecutionWorkspaceCloseReadiness,
ExecutionWorkspaceConfig, ExecutionWorkspaceConfig,
WorkspaceRuntimeDesiredState,
WorkspaceRuntimeService, WorkspaceRuntimeService,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
@@ -40,6 +41,20 @@ function cloneRecord(value: unknown): Record<string, unknown> | null {
return { ...value }; return { ...value };
} }
function readDesiredState(value: unknown): WorkspaceRuntimeDesiredState | null {
return value === "running" || value === "stopped" || value === "manual" ? value : null;
}
function readServiceStates(value: unknown): ExecutionWorkspaceConfig["serviceStates"] {
if (!isRecord(value)) return null;
const entries = Object.entries(value).filter(([, state]) =>
state === "running" || state === "stopped" || state === "manual"
);
return entries.length > 0
? Object.fromEntries(entries) as ExecutionWorkspaceConfig["serviceStates"]
: null;
}
async function pathExists(value: string | null | undefined) { async function pathExists(value: string | null | undefined) {
if (!value) return false; if (!value) return false;
try { try {
@@ -192,12 +207,8 @@ export function readExecutionWorkspaceConfig(metadata: Record<string, unknown> |
teardownCommand: readNullableString(raw.teardownCommand), teardownCommand: readNullableString(raw.teardownCommand),
cleanupCommand: readNullableString(raw.cleanupCommand), cleanupCommand: readNullableString(raw.cleanupCommand),
workspaceRuntime: cloneRecord(raw.workspaceRuntime), workspaceRuntime: cloneRecord(raw.workspaceRuntime),
desiredState: raw.desiredState === "running" || raw.desiredState === "stopped" ? raw.desiredState : null, desiredState: readDesiredState(raw.desiredState),
serviceStates: isRecord(raw.serviceStates) serviceStates: readServiceStates(raw.serviceStates),
? Object.fromEntries(
Object.entries(raw.serviceStates).filter(([, state]) => state === "running" || state === "stopped"),
) as ExecutionWorkspaceConfig["serviceStates"]
: null,
}; };
const hasConfig = Object.values(config).some((value) => { const hasConfig = Object.values(config).some((value) => {
@@ -235,18 +246,10 @@ export function mergeExecutionWorkspaceConfig(
workspaceRuntime: patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime, workspaceRuntime: patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime,
desiredState: desiredState:
patch.desiredState !== undefined patch.desiredState !== undefined
? patch.desiredState === "running" || patch.desiredState === "stopped" ? readDesiredState(patch.desiredState)
? patch.desiredState
: null
: current.desiredState, : current.desiredState,
serviceStates: serviceStates:
patch.serviceStates !== undefined && isRecord(patch.serviceStates) patch.serviceStates !== undefined ? readServiceStates(patch.serviceStates) : current.serviceStates,
? Object.fromEntries(
Object.entries(patch.serviceStates).filter(([, state]) => state === "running" || state === "stopped"),
) as ExecutionWorkspaceConfig["serviceStates"]
: patch.serviceStates !== undefined
? null
: current.serviceStates,
}; };
const hasConfig = Object.values(nextConfig).some((value) => { const hasConfig = Object.values(nextConfig).some((value) => {

View File

@@ -270,6 +270,16 @@ export function applyPersistedExecutionWorkspaceConfig(input: {
} else if (input.workspaceConfig?.workspaceRuntime) { } else if (input.workspaceConfig?.workspaceRuntime) {
nextConfig.workspaceRuntime = { ...input.workspaceConfig.workspaceRuntime }; nextConfig.workspaceRuntime = { ...input.workspaceConfig.workspaceRuntime };
} }
if (input.workspaceConfig?.desiredState === null) {
delete nextConfig.desiredState;
} else if (input.workspaceConfig?.desiredState) {
nextConfig.desiredState = input.workspaceConfig.desiredState;
}
if (input.workspaceConfig?.serviceStates === null) {
delete nextConfig.serviceStates;
} else if (input.workspaceConfig?.serviceStates) {
nextConfig.serviceStates = { ...input.workspaceConfig.serviceStates };
}
} }
if (input.workspaceConfig && input.mode === "isolated_workspace") { if (input.workspaceConfig && input.mode === "isolated_workspace") {
@@ -329,6 +339,22 @@ function buildExecutionWorkspaceConfigSnapshot(config: Record<string, unknown>):
const workspaceRuntime = parseObject(config.workspaceRuntime); const workspaceRuntime = parseObject(config.workspaceRuntime);
snapshot.workspaceRuntime = Object.keys(workspaceRuntime).length > 0 ? workspaceRuntime : null; snapshot.workspaceRuntime = Object.keys(workspaceRuntime).length > 0 ? workspaceRuntime : null;
} }
if ("desiredState" in config) {
snapshot.desiredState =
config.desiredState === "running" || config.desiredState === "stopped" || config.desiredState === "manual"
? config.desiredState
: null;
}
if ("serviceStates" in config) {
const serviceStates = parseObject(config.serviceStates);
snapshot.serviceStates = Object.keys(serviceStates).length > 0
? Object.fromEntries(
Object.entries(serviceStates).filter(([, state]) =>
state === "running" || state === "stopped" || state === "manual"
),
) as ExecutionWorkspaceConfig["serviceStates"]
: null;
}
const hasSnapshot = Object.values(snapshot).some((value) => { const hasSnapshot = Object.values(snapshot).some((value) => {
if (value === null) return false; if (value === null) return false;

View File

@@ -9,12 +9,14 @@ function cloneRecord(value: unknown): Record<string, unknown> | null {
} }
function readDesiredState(value: unknown): ProjectWorkspaceRuntimeConfig["desiredState"] { function readDesiredState(value: unknown): ProjectWorkspaceRuntimeConfig["desiredState"] {
return value === "running" || value === "stopped" ? value : null; return value === "running" || value === "stopped" || value === "manual" ? value : null;
} }
function readServiceStates(value: unknown): ProjectWorkspaceRuntimeConfig["serviceStates"] { function readServiceStates(value: unknown): ProjectWorkspaceRuntimeConfig["serviceStates"] {
if (!isRecord(value)) return null; if (!isRecord(value)) return null;
const entries = Object.entries(value).filter(([, state]) => state === "running" || state === "stopped"); const entries = Object.entries(value).filter(([, state]) =>
state === "running" || state === "stopped" || state === "manual"
);
if (entries.length === 0) return null; if (entries.length === 0) return null;
return Object.fromEntries(entries) as ProjectWorkspaceRuntimeConfig["serviceStates"]; return Object.fromEntries(entries) as ProjectWorkspaceRuntimeConfig["serviceStates"];
} }

View File

@@ -4,6 +4,7 @@ import type { Db } from "@paperclipai/db";
import { import {
agents, agents,
companySecrets, companySecrets,
executionWorkspaces,
goals, goals,
heartbeatRuns, heartbeatRuns,
issues, issues,
@@ -27,7 +28,9 @@ import type {
UpdateRoutineTrigger, UpdateRoutineTrigger,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { import {
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
getBuiltinRoutineVariableValues, getBuiltinRoutineVariableValues,
extractRoutineVariableNames,
interpolateRoutineTemplate, interpolateRoutineTemplate,
stringifyRoutineVariableValue, stringifyRoutineVariableValue,
syncRoutineVariablesWithTemplate, syncRoutineVariablesWithTemplate,
@@ -269,15 +272,23 @@ function resolveRoutineVariableValues(
source: "schedule" | "manual" | "api" | "webhook"; source: "schedule" | "manual" | "api" | "webhook";
payload?: Record<string, unknown> | null; payload?: Record<string, unknown> | null;
variables?: 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>; if (variables.length === 0) return {} as Record<string, string | number | boolean>;
const provided = collectProvidedRoutineVariables(input.source, input.payload, input.variables); const provided = collectProvidedRoutineVariables(input.source, input.payload, input.variables);
const automaticVariables = input.automaticVariables ?? {};
const resolved: Record<string, string | number | boolean> = {}; const resolved: Record<string, string | number | boolean> = {};
const missing: string[] = []; const missing: string[] = [];
for (const variable of variables) { 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); const normalized = normalizeRoutineVariableValue(variable, candidate);
if (normalized == null || (typeof normalized === "string" && normalized.trim().length === 0)) { if (normalized == null || (typeof normalized === "string" && normalized.trim().length === 0)) {
if (variable.required) missing.push(variable.name); 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 } = {}) { export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeupDeps } = {}) {
const issueSvc = issueService(db); const issueSvc = issueService(db);
const secretsSvc = secretService(db); const secretsSvc = secretService(db);
@@ -701,11 +717,34 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
if (!assigneeAgentId) { if (!assigneeAgentId) {
throw unprocessable("Default agent required"); throw unprocessable("Default agent required");
} }
const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], input); const automaticVariables: Record<string, string | number | boolean> = {};
const allVariables = { ...getBuiltinRoutineVariableValues(), ...resolvedVariables }; 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 title = interpolateRoutineTemplate(input.routine.title, allVariables) ?? input.routine.title;
const description = interpolateRoutineTemplate(input.routine.description, allVariables); 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 run = await db.transaction(async (tx) => {
const txDb = tx as unknown as Db; const txDb = tx as unknown as Db;
await tx.execute( await tx.execute(

View File

@@ -2240,13 +2240,17 @@ function readConfiguredServiceStates(config: Record<string, unknown>) {
const raw = parseObject(config.serviceStates); const raw = parseObject(config.serviceStates);
const states: WorkspaceRuntimeServiceStateMap = {}; const states: WorkspaceRuntimeServiceStateMap = {};
for (const [key, value] of Object.entries(raw)) { for (const [key, value] of Object.entries(raw)) {
if (value === "running" || value === "stopped") { if (value === "running" || value === "stopped" || value === "manual") {
states[key] = value; states[key] = value;
} }
} }
return states; return states;
} }
function readDesiredRuntimeState(value: unknown): WorkspaceRuntimeDesiredState | null {
return value === "running" || value === "stopped" || value === "manual" ? value : null;
}
export function buildWorkspaceRuntimeDesiredStatePatch(input: { export function buildWorkspaceRuntimeDesiredStatePatch(input: {
config: Record<string, unknown>; config: Record<string, unknown>;
currentDesiredState: WorkspaceRuntimeDesiredState | null; currentDesiredState: WorkspaceRuntimeDesiredState | null;
@@ -2258,7 +2262,7 @@ export function buildWorkspaceRuntimeDesiredStatePatch(input: {
serviceStates: WorkspaceRuntimeServiceStateMap | null; serviceStates: WorkspaceRuntimeServiceStateMap | null;
} { } {
const configuredServices = listConfiguredRuntimeServiceEntries(input.config); const configuredServices = listConfiguredRuntimeServiceEntries(input.config);
const fallbackState: WorkspaceRuntimeDesiredState = input.currentDesiredState === "running" ? "running" : "stopped"; const fallbackState: WorkspaceRuntimeDesiredState = readDesiredRuntimeState(input.currentDesiredState) ?? "stopped";
const nextServiceStates: WorkspaceRuntimeServiceStateMap = {}; const nextServiceStates: WorkspaceRuntimeServiceStateMap = {};
for (let index = 0; index < configuredServices.length; index += 1) { for (let index = 0; index < configuredServices.length; index += 1) {
@@ -2266,15 +2270,26 @@ export function buildWorkspaceRuntimeDesiredStatePatch(input: {
} }
const nextState: WorkspaceRuntimeDesiredState = input.action === "stop" ? "stopped" : "running"; const nextState: WorkspaceRuntimeDesiredState = input.action === "stop" ? "stopped" : "running";
const applyActionState = (index: number) => {
const key = String(index);
// Manual services are intentionally left under operator control even when
// an API action targets that individual service.
if (nextServiceStates[key] === "manual") return;
nextServiceStates[key] = nextState;
};
if (input.serviceIndex === undefined || input.serviceIndex === null) { if (input.serviceIndex === undefined || input.serviceIndex === null) {
for (let index = 0; index < configuredServices.length; index += 1) { for (let index = 0; index < configuredServices.length; index += 1) {
nextServiceStates[String(index)] = nextState; applyActionState(index);
} }
} else if (input.serviceIndex >= 0 && input.serviceIndex < configuredServices.length) { } else if (input.serviceIndex >= 0 && input.serviceIndex < configuredServices.length) {
nextServiceStates[String(input.serviceIndex)] = nextState; applyActionState(input.serviceIndex);
} }
const desiredState = Object.values(nextServiceStates).some((state) => state === "running") ? "running" : "stopped"; const desiredState = Object.values(nextServiceStates).some((state) => state === "running")
? "running"
: Object.values(nextServiceStates).some((state) => state === "manual")
? "manual"
: "stopped";
return { return {
desiredState, desiredState,
@@ -2291,7 +2306,7 @@ function selectRuntimeServiceEntries(input: {
}) { }) {
const entries = listConfiguredRuntimeServiceEntries(input.config); const entries = listConfiguredRuntimeServiceEntries(input.config);
const states = input.serviceStates ?? readConfiguredServiceStates(input.config); const states = input.serviceStates ?? readConfiguredServiceStates(input.config);
const fallbackState: WorkspaceRuntimeDesiredState = input.defaultDesiredState === "running" ? "running" : "stopped"; const fallbackState: WorkspaceRuntimeDesiredState = readDesiredRuntimeState(input.defaultDesiredState) ?? "stopped";
return entries.filter((_, index) => { return entries.filter((_, index) => {
if (input.serviceIndex !== undefined && input.serviceIndex !== null) { if (input.serviceIndex !== undefined && input.serviceIndex !== null) {
@@ -2313,7 +2328,12 @@ export async function ensureRuntimeServicesForRun(input: {
adapterEnv: Record<string, string>; adapterEnv: Record<string, string>;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}): Promise<RuntimeServiceRef[]> { }): Promise<RuntimeServiceRef[]> {
const rawServices = readRuntimeServiceEntries(input.config); const rawServices = selectRuntimeServiceEntries({
config: input.config,
respectDesiredStates: true,
defaultDesiredState: readDesiredRuntimeState(input.config.desiredState) ?? "running",
serviceStates: readConfiguredServiceStates(input.config),
});
const acquiredServiceIds: string[] = []; const acquiredServiceIds: string[] = [];
const refs: RuntimeServiceRef[] = []; const refs: RuntimeServiceRef[] = [];
runtimeServiceLeasesByRun.set(input.runId, acquiredServiceIds); runtimeServiceLeasesByRun.set(input.runId, acquiredServiceIds);
@@ -2401,7 +2421,7 @@ export async function startRuntimeServicesForWorkspaceControl(input: {
config: input.config, config: input.config,
serviceIndex: input.serviceIndex, serviceIndex: input.serviceIndex,
respectDesiredStates: input.respectDesiredStates, respectDesiredStates: input.respectDesiredStates,
defaultDesiredState: input.config.desiredState === "running" ? "running" : "stopped", defaultDesiredState: readDesiredRuntimeState(input.config.desiredState) ?? "stopped",
serviceStates: readConfiguredServiceStates(input.config), serviceStates: readConfiguredServiceStates(input.config),
}); });
const refs: RuntimeServiceRef[] = []; const refs: RuntimeServiceRef[] = [];

View File

@@ -98,7 +98,7 @@ export function ExecutionWorkspaceCloseDialog({
</DialogHeader> </DialogHeader>
{readinessQuery.isLoading ? ( {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" /> <Loader2 className="h-4 w-4 animate-spin" />
Checking whether this workspace is safe to close... Checking whether this workspace is safe to close...
</div> </div>
@@ -174,7 +174,7 @@ export function ExecutionWorkspaceCloseDialog({
{readiness.git ? ( {readiness.git ? (
<section className="space-y-2"> <section className="space-y-2">
<h3 className="text-sm font-medium">Git status</h3> <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 className="grid gap-2 sm:grid-cols-2">
<div> <div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Branch</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> <h3 className="text-sm font-medium">Other linked issues</h3>
<div className="space-y-2"> <div className="space-y-2">
{otherLinkedIssues.map((issue) => ( {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"> <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"> <Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
{issue.identifier ?? issue.id} · {issue.title} {issue.identifier ?? issue.id} · {issue.title}
@@ -230,7 +230,7 @@ export function ExecutionWorkspaceCloseDialog({
<h3 className="text-sm font-medium">Attached runtime services</h3> <h3 className="text-sm font-medium">Attached runtime services</h3>
<div className="space-y-2"> <div className="space-y-2">
{readiness.runtimeServices.map((service) => ( {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"> <div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<span className="font-medium">{service.serviceName}</span> <span className="font-medium">{service.serviceName}</span>
<span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</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> <h3 className="text-sm font-medium">Cleanup actions</h3>
<div className="space-y-2"> <div className="space-y-2">
{readiness.plannedActions.map((action, index) => ( {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="font-medium">{action.label}</div>
<div className="mt-1 break-words text-muted-foreground">{action.description}</div> <div className="mt-1 break-words text-muted-foreground">{action.description}</div>
{action.command ? ( {action.command ? (
@@ -269,7 +269,7 @@ export function ExecutionWorkspaceCloseDialog({
) : null} ) : null}
{currentStatus === "archived" ? ( {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. This workspace is already archived.
</div> </div>
) : null} ) : null}

View File

@@ -200,7 +200,7 @@ interface IssueWorkspaceCardProps {
onUpdate: (data: Record<string, unknown>) => void; onUpdate: (data: Record<string, unknown>) => void;
initialEditing?: boolean; initialEditing?: boolean;
livePreview?: 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({ export function IssueWorkspaceCard({
@@ -298,6 +298,10 @@ export function IssueWorkspaceCard({
}); });
const canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0; const canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0;
const draftWorkspaceBranchName =
draftSelection === "reuse_existing" && configuredReusableWorkspace?.mode !== "shared_workspace"
? configuredReusableWorkspace?.branchName ?? null
: null;
const buildWorkspaceDraftUpdate = useCallback(() => ({ const buildWorkspaceDraftUpdate = useCallback(() => ({
executionWorkspacePreference: draftSelection, executionWorkspacePreference: draftSelection,
@@ -316,8 +320,11 @@ export function IssueWorkspaceCard({
useEffect(() => { useEffect(() => {
if (!onDraftChange) return; if (!onDraftChange) return;
onDraftChange(buildWorkspaceDraftUpdate(), { canSave: canSaveWorkspaceConfig }); onDraftChange(buildWorkspaceDraftUpdate(), {
}, [buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, onDraftChange]); canSave: canSaveWorkspaceConfig,
workspaceBranchName: draftWorkspaceBranchName,
});
}, [buildWorkspaceDraftUpdate, canSaveWorkspaceConfig, draftWorkspaceBranchName, onDraftChange]);
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
if (!canSaveWorkspaceConfig) return; if (!canSaveWorkspaceConfig) return;

View File

@@ -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="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 space-y-2"> <div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-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)} {workspaceKindLabel(summary.kind)}
</span> </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"> <span className="inline-flex items-center rounded-full border border-border/70 bg-background px-2.5 py-1 text-xs text-muted-foreground">

View File

@@ -8,6 +8,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog"; import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog";
let issueWorkspaceDraftCalls = 0; 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", () => ({ vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: { instanceSettingsApi: {
@@ -22,18 +28,20 @@ vi.mock("./IssueWorkspaceCard", async () => {
IssueWorkspaceCard: ({ IssueWorkspaceCard: ({
onDraftChange, onDraftChange,
}: { }: {
onDraftChange?: (data: Record<string, unknown>, meta: { canSave: boolean }) => void; onDraftChange?: (
data: Record<string, unknown>,
meta: { canSave: boolean; workspaceBranchName?: string | null },
) => void;
}) => { }) => {
React.useEffect(() => { React.useEffect(() => {
issueWorkspaceDraftCalls += 1; issueWorkspaceDraftCalls += 1;
if (issueWorkspaceDraftCalls > 20) { if (issueWorkspaceDraftCalls > 20) {
throw new Error("IssueWorkspaceCard onDraftChange looped"); throw new Error("IssueWorkspaceCard onDraftChange looped");
} }
onDraftChange?.({ onDraftChange?.(issueWorkspaceDraft, {
executionWorkspaceId: null, canSave: true,
executionWorkspacePreference: "shared_workspace", workspaceBranchName: issueWorkspaceBranchName,
executionWorkspaceSettings: { mode: "shared_workspace" }, });
}, { canSave: true });
}, [onDraftChange]); }, [onDraftChange]);
return <div data-testid="workspace-card">Workspace card</div>; return <div data-testid="workspace-card">Workspace card</div>;
@@ -119,6 +127,12 @@ describe("RoutineRunVariablesDialog", () => {
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
issueWorkspaceDraftCalls = 0; issueWorkspaceDraftCalls = 0;
issueWorkspaceDraft = {
executionWorkspaceId: null,
executionWorkspacePreference: "shared_workspace",
executionWorkspaceSettings: { mode: "shared_workspace" },
};
issueWorkspaceBranchName = null;
}); });
afterEach(() => { afterEach(() => {
@@ -155,6 +169,7 @@ describe("RoutineRunVariablesDialog", () => {
); );
await Promise.resolve(); await Promise.resolve();
await Promise.resolve(); await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}); });
expect(issueWorkspaceDraftCalls).toBeLessThanOrEqual(2); expect(issueWorkspaceDraftCalls).toBeLessThanOrEqual(2);
@@ -166,4 +181,87 @@ describe("RoutineRunVariablesDialog", () => {
root.unmount(); 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();
});
});
}); });

View File

@@ -1,5 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react"; 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 { useQuery } from "@tanstack/react-query";
import { instanceSettingsApi } from "../api/instanceSettings"; import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
@@ -189,6 +195,7 @@ export function RoutineRunVariablesDialog({
: null; : null;
const [workspaceConfig, setWorkspaceConfig] = useState(() => buildInitialWorkspaceConfig(selectedProject)); const [workspaceConfig, setWorkspaceConfig] = useState(() => buildInitialWorkspaceConfig(selectedProject));
const [workspaceConfigValid, setWorkspaceConfigValid] = useState(true); const [workspaceConfigValid, setWorkspaceConfigValid] = useState(true);
const [workspaceBranchName, setWorkspaceBranchName] = useState<string | null>(null);
const { data: experimentalSettings } = useQuery({ const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings, queryKey: queryKeys.instance.experimentalSettings,
@@ -208,15 +215,27 @@ export function RoutineRunVariablesDialog({
setSelection(nextSelection); setSelection(nextSelection);
setWorkspaceConfig(buildInitialWorkspaceConfig(projects.find((project) => project.id === nextSelection.projectId) ?? null)); setWorkspaceConfig(buildInitialWorkspaceConfig(projects.find((project) => project.id === nextSelection.projectId) ?? null));
setWorkspaceConfigValid(true); setWorkspaceConfigValid(true);
setWorkspaceBranchName(null);
}, [defaultAssigneeAgentId, defaultProjectId, open, projects, variables]); }, [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( const missingRequired = useMemo(
() => () =>
variables variables
.filter((variable) => variable.required) .filter((variable) => variable.required)
.filter((variable) => !isAutoWorkspaceBranchVariable(variable))
.filter((variable) => isMissingRequiredValue(values[variable.name])) .filter((variable) => isMissingRequiredValue(values[variable.name]))
.map((variable) => variable.label || variable.name), .map((variable) => variable.label || variable.name),
[values, variables], [isAutoWorkspaceBranchVariable, values, variables],
); );
const workspaceIssue = useMemo(() => ({ const workspaceIssue = useMemo(() => ({
@@ -247,10 +266,14 @@ export function RoutineRunVariablesDialog({
const handleWorkspaceDraftChange = useCallback(( const handleWorkspaceDraftChange = useCallback((
data: Record<string, unknown>, data: Record<string, unknown>,
meta: { canSave: boolean }, meta: { canSave: boolean; workspaceBranchName?: string | null },
) => { ) => {
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data)); setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
setWorkspaceConfigValid((current) => (current === meta.canSave ? current : meta.canSave)); setWorkspaceConfigValid((current) => (current === meta.canSave ? current : meta.canSave));
setWorkspaceBranchName((current) => {
const next = meta.workspaceBranchName ?? null;
return current === next ? current : next;
});
}, []); }, []);
return ( return (
@@ -328,6 +351,7 @@ export function RoutineRunVariablesDialog({
setSelection((current) => ({ ...current, projectId })); setSelection((current) => ({ ...current, projectId }));
setWorkspaceConfig(buildInitialWorkspaceConfig(project)); setWorkspaceConfig(buildInitialWorkspaceConfig(project));
setWorkspaceConfigValid(true); setWorkspaceConfigValid(true);
setWorkspaceBranchName(null);
}} }}
renderTriggerValue={(option) => renderTriggerValue={(option) =>
option && selectedProject ? ( option && selectedProject ? (
@@ -365,7 +389,13 @@ export function RoutineRunVariablesDialog({
{variable.label || variable.name} {variable.label || variable.name}
{variable.required ? " *" : ""} {variable.required ? " *" : ""}
</Label> </Label>
{variable.type === "textarea" ? ( {isAutoWorkspaceBranchVariable(variable) ? (
<Input
readOnly
disabled
value={workspaceBranchAutoValue ?? ""}
/>
) : variable.type === "textarea" ? (
<Textarea <Textarea
rows={4} rows={4}
value={typeof values[variable.name] === "string" ? values[variable.name] as string : ""} value={typeof values[variable.name] === "string" ? values[variable.name] as string : ""}
@@ -450,6 +480,10 @@ export function RoutineRunVariablesDialog({
onClick={() => { onClick={() => {
const nextVariables: Record<string, string | number | boolean> = {}; const nextVariables: Record<string, string | number | boolean> = {};
for (const variable of variables) { for (const variable of variables) {
if (isAutoWorkspaceBranchVariable(variable)) {
nextVariables[variable.name] = workspaceBranchAutoValue!;
continue;
}
const rawValue = values[variable.name]; const rawValue = values[variable.name];
if (isMissingRequiredValue(rawValue)) continue; if (isMissingRequiredValue(rawValue)) continue;
if (variable.type === "number") { if (variable.type === "number") {

View File

@@ -709,7 +709,7 @@ export function ExecutionWorkspaceDetail() {
<div className="space-y-4"> <div className="space-y-4">
<div className="text-xs font-medium uppercase tracking-widest text-muted-foreground">Runtime config</div> <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="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<div className="space-y-1"> <div className="space-y-1">
<div className="text-sm font-medium text-foreground"> <div className="text-sm font-medium text-foreground">
@@ -741,7 +741,7 @@ export function ExecutionWorkspaceDetail() {
</div> </div>
</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> <summary className="cursor-pointer text-sm font-medium">Advanced runtime JSON</summary>
<p className="mt-2 text-sm text-muted-foreground"> <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. 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 ? ( ) : workspaceOperationsQuery.data && workspaceOperationsQuery.data.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
{workspaceOperationsQuery.data.map((operation) => ( {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="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1"> <div className="space-y-1">
<div className="text-sm font-medium">{operation.command ?? operation.phase}</div> <div className="text-sm font-medium">{operation.command ?? operation.phase}</div>

View File

@@ -545,7 +545,7 @@ export function ProjectWorkspaceDetail() {
</Field> </Field>
</div> </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> <summary className="cursor-pointer text-sm font-medium">Advanced runtime JSON</summary>
<p className="mt-2 text-sm text-muted-foreground"> <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. Paperclip derives Services and Jobs from this JSON. Prefer editing named commands first; use raw JSON for advanced lifecycle, port, readiness, or environment settings.