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