diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 035e7bb7ed..810bfd93e4 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -68,6 +68,8 @@ export const AGENT_ROLE_LABELS: Record = { export const AGENT_DEFAULT_MAX_CONCURRENT_RUNS = 5; +export const WORKSPACE_BRANCH_ROUTINE_VARIABLE = "workspaceBranch"; + export const AGENT_ICON_NAMES = [ "bot", "cpu", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d29900567c..66635dd8bb 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -10,6 +10,7 @@ export { AGENT_ROLES, AGENT_ROLE_LABELS, AGENT_DEFAULT_MAX_CONCURRENT_RUNS, + WORKSPACE_BRANCH_ROUTINE_VARIABLE, AGENT_ICON_NAMES, ISSUE_STATUSES, INBOX_MINE_ISSUE_STATUSES, diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 4e6b01cdac..b383f62a2d 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -45,7 +45,7 @@ export type ExecutionWorkspaceCloseActionKind = | "git_branch_delete" | "remove_local_directory"; -export type WorkspaceRuntimeDesiredState = "running" | "stopped"; +export type WorkspaceRuntimeDesiredState = "running" | "stopped" | "manual"; export type WorkspaceRuntimeServiceStateMap = Record; export type WorkspaceCommandKind = "service" | "job"; diff --git a/packages/shared/src/validators/execution-workspace.ts b/packages/shared/src/validators/execution-workspace.ts index db5507e099..4a25ba9074 100644 --- a/packages/shared/src/validators/execution-workspace.ts +++ b/packages/shared/src/validators/execution-workspace.ts @@ -13,8 +13,8 @@ export const executionWorkspaceConfigSchema = z.object({ teardownCommand: z.string().optional().nullable(), cleanupCommand: z.string().optional().nullable(), workspaceRuntime: z.record(z.unknown()).optional().nullable(), - desiredState: z.enum(["running", "stopped"]).optional().nullable(), - serviceStates: z.record(z.enum(["running", "stopped"])).optional().nullable(), + desiredState: z.enum(["running", "stopped", "manual"]).optional().nullable(), + serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(), }).strict(); export const workspaceRuntimeControlTargetSchema = z.object({ diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index 8e8549ca2e..4f815db22c 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -30,8 +30,8 @@ export const projectExecutionWorkspacePolicySchema = z export const projectWorkspaceRuntimeConfigSchema = z.object({ workspaceRuntime: z.record(z.unknown()).optional().nullable(), - desiredState: z.enum(["running", "stopped"]).optional().nullable(), - serviceStates: z.record(z.enum(["running", "stopped"])).optional().nullable(), + desiredState: z.enum(["running", "stopped", "manual"]).optional().nullable(), + serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(), }).strict(); const projectWorkspaceSourceTypeSchema = z.enum(["local_path", "git_repo", "remote_managed", "non_git_path"]); diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index abaac6ac1e..e22d06d890 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -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 () => { const { companyId, agentId, projectId, svc } = await seedFixture(); const draftRoutine = await svc.create( diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 50e89c017c..a2ed2595bd 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -2035,6 +2035,37 @@ describe("realizeExecutionWorkspace", () => { }); 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 () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-workspace-")); 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", () => { diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index 07895b92ed..ba2f9180f2 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -8,6 +8,7 @@ import { updateExecutionWorkspaceSchema, workspaceRuntimeControlTargetSchema, } from "@paperclipai/shared"; +import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.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; } - const currentDesiredState: "running" | "stopped" = + const currentDesiredState: WorkspaceRuntimeDesiredState = existing.config?.desiredState ?? ((existing.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running") ? "running" : "stopped"); const nextRuntimeState: { - desiredState: "running" | "stopped"; - serviceStates: Record | null | undefined; + desiredState: WorkspaceRuntimeDesiredState; + serviceStates: WorkspaceRuntimeServiceStateMap | null | undefined; } = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null) ? { desiredState: currentDesiredState, diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 036b0de60f..6b83aac5c2 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -10,6 +10,7 @@ import { updateProjectWorkspaceSchema, workspaceRuntimeControlTargetSchema, } from "@paperclipai/shared"; +import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared"; import { trackProjectCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.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; } - const currentDesiredState: "running" | "stopped" = + const currentDesiredState: WorkspaceRuntimeDesiredState = workspace.runtimeConfig?.desiredState ?? ((workspace.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running") ? "running" : "stopped"); const nextRuntimeState: { - desiredState: "running" | "stopped"; - serviceStates: Record | null | undefined; + desiredState: WorkspaceRuntimeDesiredState; + serviceStates: WorkspaceRuntimeServiceStateMap | null | undefined; } = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null) ? { desiredState: currentDesiredState, diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 1832a1a1b5..6bbc05dabd 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -12,6 +12,7 @@ import type { ExecutionWorkspaceCloseGitReadiness, ExecutionWorkspaceCloseReadiness, ExecutionWorkspaceConfig, + WorkspaceRuntimeDesiredState, WorkspaceRuntimeService, } from "@paperclipai/shared"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; @@ -40,6 +41,20 @@ function cloneRecord(value: unknown): Record | null { 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) { if (!value) return false; try { @@ -192,12 +207,8 @@ export function readExecutionWorkspaceConfig(metadata: Record | teardownCommand: readNullableString(raw.teardownCommand), cleanupCommand: readNullableString(raw.cleanupCommand), workspaceRuntime: cloneRecord(raw.workspaceRuntime), - desiredState: raw.desiredState === "running" || raw.desiredState === "stopped" ? raw.desiredState : null, - serviceStates: isRecord(raw.serviceStates) - ? Object.fromEntries( - Object.entries(raw.serviceStates).filter(([, state]) => state === "running" || state === "stopped"), - ) as ExecutionWorkspaceConfig["serviceStates"] - : null, + desiredState: readDesiredState(raw.desiredState), + serviceStates: readServiceStates(raw.serviceStates), }; const hasConfig = Object.values(config).some((value) => { @@ -235,18 +246,10 @@ export function mergeExecutionWorkspaceConfig( workspaceRuntime: patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime, desiredState: patch.desiredState !== undefined - ? patch.desiredState === "running" || patch.desiredState === "stopped" - ? patch.desiredState - : null + ? readDesiredState(patch.desiredState) : current.desiredState, serviceStates: - patch.serviceStates !== undefined && isRecord(patch.serviceStates) - ? Object.fromEntries( - Object.entries(patch.serviceStates).filter(([, state]) => state === "running" || state === "stopped"), - ) as ExecutionWorkspaceConfig["serviceStates"] - : patch.serviceStates !== undefined - ? null - : current.serviceStates, + patch.serviceStates !== undefined ? readServiceStates(patch.serviceStates) : current.serviceStates, }; const hasConfig = Object.values(nextConfig).some((value) => { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index bfc1c79fca..303efd735f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -270,6 +270,16 @@ export function applyPersistedExecutionWorkspaceConfig(input: { } else if (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") { @@ -329,6 +339,22 @@ function buildExecutionWorkspaceConfigSnapshot(config: Record): const workspaceRuntime = parseObject(config.workspaceRuntime); 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) => { if (value === null) return false; diff --git a/server/src/services/project-workspace-runtime-config.ts b/server/src/services/project-workspace-runtime-config.ts index 9a36c95795..8c3e30064e 100644 --- a/server/src/services/project-workspace-runtime-config.ts +++ b/server/src/services/project-workspace-runtime-config.ts @@ -9,12 +9,14 @@ function cloneRecord(value: unknown): Record | null { } 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"] { 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; return Object.fromEntries(entries) as ProjectWorkspaceRuntimeConfig["serviceStates"]; } diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 80dfd33a8f..bc24c5966e 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -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 | null; variables?: Record | null; + automaticVariables?: Record; }, ) { if (variables.length === 0) return {} as Record; const provided = collectProvidedRoutineVariables(input.source, input.payload, input.variables); + const automaticVariables = input.automaticVariables ?? {}; const resolved: Record = {}; 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 = {}; + 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( diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index f3a556c0cf..7ff44f7add 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -2240,13 +2240,17 @@ function readConfiguredServiceStates(config: Record) { const raw = parseObject(config.serviceStates); const states: WorkspaceRuntimeServiceStateMap = {}; for (const [key, value] of Object.entries(raw)) { - if (value === "running" || value === "stopped") { + if (value === "running" || value === "stopped" || value === "manual") { states[key] = value; } } return states; } +function readDesiredRuntimeState(value: unknown): WorkspaceRuntimeDesiredState | null { + return value === "running" || value === "stopped" || value === "manual" ? value : null; +} + export function buildWorkspaceRuntimeDesiredStatePatch(input: { config: Record; currentDesiredState: WorkspaceRuntimeDesiredState | null; @@ -2258,7 +2262,7 @@ export function buildWorkspaceRuntimeDesiredStatePatch(input: { serviceStates: WorkspaceRuntimeServiceStateMap | null; } { const configuredServices = listConfiguredRuntimeServiceEntries(input.config); - const fallbackState: WorkspaceRuntimeDesiredState = input.currentDesiredState === "running" ? "running" : "stopped"; + const fallbackState: WorkspaceRuntimeDesiredState = readDesiredRuntimeState(input.currentDesiredState) ?? "stopped"; const nextServiceStates: WorkspaceRuntimeServiceStateMap = {}; 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 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) { for (let index = 0; index < configuredServices.length; index += 1) { - nextServiceStates[String(index)] = nextState; + applyActionState(index); } } 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 { desiredState, @@ -2291,7 +2306,7 @@ function selectRuntimeServiceEntries(input: { }) { const entries = listConfiguredRuntimeServiceEntries(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) => { if (input.serviceIndex !== undefined && input.serviceIndex !== null) { @@ -2313,7 +2328,12 @@ export async function ensureRuntimeServicesForRun(input: { adapterEnv: Record; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; }): Promise { - 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 refs: RuntimeServiceRef[] = []; runtimeServiceLeasesByRun.set(input.runId, acquiredServiceIds); @@ -2401,7 +2421,7 @@ export async function startRuntimeServicesForWorkspaceControl(input: { config: input.config, serviceIndex: input.serviceIndex, respectDesiredStates: input.respectDesiredStates, - defaultDesiredState: input.config.desiredState === "running" ? "running" : "stopped", + defaultDesiredState: readDesiredRuntimeState(input.config.desiredState) ?? "stopped", serviceStates: readConfiguredServiceStates(input.config), }); const refs: RuntimeServiceRef[] = []; diff --git a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx index fe4477966a..077a6bd844 100644 --- a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx +++ b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx @@ -98,7 +98,7 @@ export function ExecutionWorkspaceCloseDialog({ {readinessQuery.isLoading ? ( -
+
Checking whether this workspace is safe to close...
@@ -174,7 +174,7 @@ export function ExecutionWorkspaceCloseDialog({ {readiness.git ? (

Git status

-
+
Branch
@@ -212,7 +212,7 @@ export function ExecutionWorkspaceCloseDialog({

Other linked issues

{otherLinkedIssues.map((issue) => ( -
+
{issue.identifier ?? issue.id} · {issue.title} @@ -230,7 +230,7 @@ export function ExecutionWorkspaceCloseDialog({

Attached runtime services

{readiness.runtimeServices.map((service) => ( -
+
{service.serviceName} {service.status} · {service.lifecycle} @@ -248,7 +248,7 @@ export function ExecutionWorkspaceCloseDialog({

Cleanup actions

{readiness.plannedActions.map((action, index) => ( -
+
{action.label}
{action.description}
{action.command ? ( @@ -269,7 +269,7 @@ export function ExecutionWorkspaceCloseDialog({ ) : null} {currentStatus === "archived" ? ( -
+
This workspace is already archived.
) : null} diff --git a/ui/src/components/IssueWorkspaceCard.tsx b/ui/src/components/IssueWorkspaceCard.tsx index 982f444b92..18f9d90846 100644 --- a/ui/src/components/IssueWorkspaceCard.tsx +++ b/ui/src/components/IssueWorkspaceCard.tsx @@ -200,7 +200,7 @@ interface IssueWorkspaceCardProps { onUpdate: (data: Record) => void; initialEditing?: boolean; livePreview?: boolean; - onDraftChange?: (data: Record, meta: { canSave: boolean }) => void; + onDraftChange?: (data: Record, 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; diff --git a/ui/src/components/ProjectWorkspaceSummaryCard.tsx b/ui/src/components/ProjectWorkspaceSummaryCard.tsx index 6e0ae8f685..579314fd0d 100644 --- a/ui/src/components/ProjectWorkspaceSummaryCard.tsx +++ b/ui/src/components/ProjectWorkspaceSummaryCard.tsx @@ -59,7 +59,7 @@ export function ProjectWorkspaceSummaryCard({
- + {workspaceKindLabel(summary.kind)} diff --git a/ui/src/components/RoutineRunVariablesDialog.test.tsx b/ui/src/components/RoutineRunVariablesDialog.test.tsx index a2b0236f5d..e3a1886181 100644 --- a/ui/src/components/RoutineRunVariablesDialog.test.tsx +++ b/ui/src/components/RoutineRunVariablesDialog.test.tsx @@ -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, meta: { canSave: boolean }) => void; + onDraftChange?: ( + data: Record, + 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
Workspace card
; @@ -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( + + {}} + 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} + /> + , + ); + 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(); + }); + }); }); diff --git a/ui/src/components/RoutineRunVariablesDialog.tsx b/ui/src/components/RoutineRunVariablesDialog.tsx index a5d78cefbf..b027b2d5be 100644 --- a/ui/src/components/RoutineRunVariablesDialog.tsx +++ b/ui/src/components/RoutineRunVariablesDialog.tsx @@ -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(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, - 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 ? " *" : ""} - {variable.type === "textarea" ? ( + {isAutoWorkspaceBranchVariable(variable) ? ( + + ) : variable.type === "textarea" ? (