[codex] Improve workspace runtime and navigation ergonomics (#3680)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - That operator experience depends not just on issue chat, but also on
how workspaces, inbox groups, and navigation state behave over
long-running sessions
> - The current branch included a separate cluster of workspace-runtime
controls, inbox grouping, sidebar ordering, and worktree lifecycle fixes
> - Those changes cross server, shared contracts, database state, and UI
navigation, but they still form one coherent operator workflow area
> - This pull request isolates the workspace/runtime and navigation
ergonomics work into one standalone branch
> - The benefit is better workspace recovery and navigation persistence
without forcing reviewers through the unrelated issue-detail/chat work

## What Changed

- Improved execution workspace and project workspace controls, request
wiring, layout, and JSON editor ergonomics
- Hardened linked worktree reuse/startup behavior and documented the
`worktree repair` flow for recovering linked worktrees safely
- Added inbox workspace grouping, mobile collapse, archive undo,
keyboard navigation, shared group-header styling, and persisted
collapsed-group behavior
- Added persistent sidebar order preferences with the supporting DB
migration, shared/server contracts, routes, services, hooks, and UI
integration
- Scoped issue-list preferences by context and added targeted UI/server
tests for workspace controls, inbox behavior, sidebar preferences, and
worktree validation

## Verification

- `pnpm vitest run
server/src/__tests__/sidebar-preferences-routes.test.ts
ui/src/pages/Inbox.test.tsx
ui/src/components/ProjectWorkspaceSummaryCard.test.tsx
ui/src/components/WorkspaceRuntimeControls.test.tsx
ui/src/api/workspace-runtime-control.test.ts`
- `server/src/__tests__/workspace-runtime.test.ts` was attempted, but
the embedded Postgres suite self-skipped/hung on this host after
reporting an init-script issue, so it is not counted as a local pass
here

## Risks

- Medium: this branch includes migration-backed preference storage plus
worktree/runtime behavior, so merge review should pay attention to state
persistence and worktree recovery semantics
- The sidebar preference migration is standalone, but it should still be
watched for conflicts if another migration lands first

## Model Used

- OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact
deployed model ID is not exposed in this environment), reasoning
enabled, tool use and local code execution enabled

## 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)
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [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-14 12:57:11 -05:00 committed by GitHub
parent 6e6f538630
commit e89076148a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 18576 additions and 1063 deletions

View file

@ -8,6 +8,11 @@ import { setTimeout as delay } from "node:timers/promises";
import type { AdapterRuntimeServiceReport } from "@paperclipai/adapter-utils";
import type { Db } from "@paperclipai/db";
import { executionWorkspaces, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
import {
listWorkspaceServiceCommandDefinitions,
type WorkspaceRuntimeDesiredState,
type WorkspaceRuntimeServiceStateMap,
} from "@paperclipai/shared";
import { and, desc, eq, inArray } from "drizzle-orm";
import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js";
import { resolveHomeAwarePath } from "../home-paths.js";
@ -26,7 +31,11 @@ import { readExecutionWorkspaceConfig } from "./execution-workspaces.js";
import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
export function resolveShell(): string {
return process.env.SHELL?.trim() || (process.platform === "win32" ? "sh" : "/bin/sh");
const fallback = process.platform === "win32" ? "sh" : "/bin/sh";
const shell = process.env.SHELL?.trim();
if (!shell) return fallback;
if (path.isAbsolute(shell) && !existsSync(shell)) return fallback;
return shell;
}
export interface ExecutionWorkspaceInput {
@ -604,6 +613,56 @@ async function directoryExists(value: string) {
return fs.stat(value).then((stats) => stats.isDirectory()).catch(() => false);
}
async function listLinkedGitWorktreePaths(repoRoot: string): Promise<Set<string>> {
const output = await runGit(["worktree", "list", "--porcelain"], repoRoot);
const paths = new Set<string>();
for (const line of output.split("\n")) {
if (!line.startsWith("worktree ")) continue;
const worktree = line.slice("worktree ".length).trim();
if (!worktree) continue;
paths.add(path.resolve(worktree));
}
return paths;
}
async function validateLinkedGitWorktree(input: {
repoRoot: string;
worktreePath: string;
expectedBranchName: string | null;
}): Promise<{ valid: true } | { valid: false; reason: string }> {
const resolvedWorktreePath = path.resolve(input.worktreePath);
const listedWorktrees = await listLinkedGitWorktreePaths(input.repoRoot);
if (!listedWorktrees.has(resolvedWorktreePath)) {
return {
valid: false,
reason: "path is not registered in `git worktree list`",
};
}
const worktreeTopLevel = await runGit(["rev-parse", "--show-toplevel"], resolvedWorktreePath).catch(() => null);
if (!worktreeTopLevel || path.resolve(worktreeTopLevel) !== resolvedWorktreePath) {
return {
valid: false,
reason: "git resolves this path to a different repository root",
};
}
if (input.expectedBranchName) {
const currentBranch = await runGit(
["symbolic-ref", "--quiet", "--short", "HEAD"],
resolvedWorktreePath,
).catch(() => null);
if (currentBranch !== input.expectedBranchName) {
return {
valid: false,
reason: `worktree HEAD is on "${currentBranch ?? "<detached>"}" instead of "${input.expectedBranchName}"`,
};
}
}
return { valid: true };
}
function terminateChildProcess(child: ChildProcess) {
if (!child.pid) return;
if (process.platform !== "win32") {
@ -777,13 +836,13 @@ async function recordWorkspaceCommandOperation(
) {
if (!recorder) {
await runWorkspaceCommand(input);
return;
return null;
}
let stdout = "";
let stderr = "";
let code: number | null = null;
await recorder.recordOperation({
const operation = await recorder.recordOperation({
phase: input.phase,
command: input.command,
cwd: input.cwd,
@ -818,7 +877,7 @@ async function recordWorkspaceCommandOperation(
},
});
if (code === 0) return;
if (code === 0) return operation;
const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
throw new Error(
@ -1004,18 +1063,32 @@ export async function realizeExecutionWorkspace(input: {
};
}
async function validateReusableWorktree(reusablePath: string) {
return await validateLinkedGitWorktree({
repoRoot,
worktreePath: reusablePath,
expectedBranchName: branchName,
}).catch(() => null);
}
const existingWorktree = await directoryExists(worktreePath);
if (existingWorktree && await isGitCheckout(worktreePath)) {
return await reuseExistingWorktree(worktreePath);
if (existingWorktree) {
const validation = await validateReusableWorktree(worktreePath);
if (validation?.valid) {
return await reuseExistingWorktree(worktreePath);
}
const reason = validation && !validation.valid ? ` (${validation.reason})` : "";
throw new Error(`Configured worktree path "${worktreePath}" already exists and is not a reusable git worktree${reason}.`);
}
const registeredBranchWorktree = await findRegisteredGitWorktreeByBranch(repoRoot, branchName);
if (registeredBranchWorktree && await isGitCheckout(registeredBranchWorktree)) {
return await reuseExistingWorktree(registeredBranchWorktree);
}
if (existingWorktree) {
throw new Error(`Configured worktree path "${worktreePath}" already exists and is not a git worktree.`);
if (registeredBranchWorktree) {
const validation = await validateReusableWorktree(registeredBranchWorktree);
if (validation?.valid) {
return await reuseExistingWorktree(registeredBranchWorktree);
}
const reason = validation && !validation.valid ? ` (${validation.reason})` : "";
throw new Error(`Registered worktree for branch "${branchName}" at "${registeredBranchWorktree}" is not reusable${reason}.`);
}
try {
@ -1087,6 +1160,147 @@ export async function realizeExecutionWorkspace(input: {
};
}
export async function ensurePersistedExecutionWorkspaceAvailable(input: {
base: ExecutionWorkspaceInput;
workspace: {
mode: string | null | undefined;
strategyType: string | null | undefined;
cwd: string | null | undefined;
providerRef: string | null | undefined;
projectId: string | null | undefined;
projectWorkspaceId: string | null | undefined;
repoUrl: string | null | undefined;
baseRef: string | null | undefined;
branchName: string | null | undefined;
config?: {
provisionCommand?: string | null;
} | null;
};
issue: ExecutionWorkspaceIssueRef | null;
agent: ExecutionWorkspaceAgentRef;
recorder?: WorkspaceOperationRecorder | null;
}): Promise<RealizedExecutionWorkspace | null> {
const cwd = asString(input.workspace.cwd ?? input.workspace.providerRef, "").trim();
if (!cwd) return null;
const strategy = input.workspace.strategyType === "git_worktree" ? "git_worktree" : "project_primary";
const realized: RealizedExecutionWorkspace = {
baseCwd: input.base.baseCwd,
source: input.workspace.mode === "shared_workspace" ? "project_primary" : "task_session",
projectId: input.workspace.projectId ?? input.base.projectId,
workspaceId: input.workspace.projectWorkspaceId ?? input.base.workspaceId,
repoUrl: input.workspace.repoUrl ?? input.base.repoUrl,
repoRef: input.workspace.baseRef ?? input.base.repoRef,
strategy,
cwd,
branchName: input.workspace.branchName ?? null,
worktreePath: strategy === "git_worktree" ? (input.workspace.providerRef ?? cwd) : null,
warnings: [],
created: false,
};
const provisionCommand = asString(input.workspace.config?.provisionCommand, "").trim();
if (strategy !== "git_worktree") {
return realized;
}
if (await directoryExists(cwd)) {
if (provisionCommand) {
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
await provisionExecutionWorktree({
strategy: {
type: "git_worktree",
provisionCommand,
},
base: input.base,
repoRoot,
worktreePath: realized.worktreePath ?? cwd,
branchName: realized.branchName ?? "",
issue: input.issue,
agent: input.agent,
created: false,
recorder: input.recorder ?? null,
});
}
return realized;
}
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
const worktreePath = realized.worktreePath ?? cwd;
const branchName = asString(input.workspace.branchName, "").trim();
if (!branchName) {
throw new Error(`Execution workspace "${cwd}" is missing and cannot be restored because no branch name is recorded.`);
}
await fs.mkdir(path.dirname(worktreePath), { recursive: true });
await runGit(["worktree", "prune"], repoRoot).catch(() => {});
let created = false;
try {
await recordGitOperation(input.recorder, {
phase: "worktree_prepare",
args: ["worktree", "add", worktreePath, branchName],
cwd: repoRoot,
metadata: {
repoRoot,
worktreePath,
branchName,
baseRef: input.workspace.baseRef ?? input.base.repoRef ?? null,
created: false,
restored: true,
},
successMessage: `Reattached missing git worktree at ${worktreePath}\n`,
failureLabel: `git worktree add ${worktreePath}`,
});
} catch (error) {
if (
!gitErrorIncludes(error, "invalid reference")
&& !gitErrorIncludes(error, "not a commit")
&& !gitErrorIncludes(error, "unknown revision")
) {
throw error;
}
const baseRef = input.workspace.baseRef ?? await detectDefaultBranch(repoRoot) ?? "HEAD";
await recordGitOperation(input.recorder, {
phase: "worktree_prepare",
args: ["worktree", "add", "-b", branchName, worktreePath, baseRef],
cwd: repoRoot,
metadata: {
repoRoot,
worktreePath,
branchName,
baseRef,
created: true,
restored: true,
},
successMessage: `Recreated missing git worktree at ${worktreePath}\n`,
failureLabel: `git worktree add ${worktreePath}`,
});
created = true;
}
await provisionExecutionWorktree({
strategy: {
type: "git_worktree",
...(provisionCommand ? { provisionCommand } : {}),
},
base: input.base,
repoRoot,
worktreePath,
branchName,
issue: input.issue,
agent: input.agent,
created,
recorder: input.recorder ?? null,
});
return {
...realized,
cwd: worktreePath,
worktreePath,
created,
};
}
export async function cleanupExecutionWorkspaceArtifacts(input: {
workspace: {
id: string;
@ -1380,6 +1594,83 @@ function resolveRuntimeServiceReuseIdentity(input: {
};
}
function resolveWorkspaceCommandExecution(input: {
command: Record<string, unknown>;
workspace: RealizedExecutionWorkspace;
agent: ExecutionWorkspaceAgentRef;
issue: ExecutionWorkspaceIssueRef | null;
adapterEnv: Record<string, string>;
}) {
const name =
asString(input.command.name, "")
|| asString(input.command.label, "")
|| asString(input.command.title, "")
|| "workspace command";
const command = asString(input.command.command, "");
const templateData = buildTemplateData({
workspace: input.workspace,
agent: input.agent,
issue: input.issue,
adapterEnv: input.adapterEnv,
port: null,
});
const cwd = resolveConfiguredPath(
renderTemplate(asString(input.command.cwd, "."), templateData),
input.workspace.cwd,
);
const env = {
...sanitizeRuntimeServiceBaseEnv(process.env),
...input.adapterEnv,
...renderRuntimeServiceEnv({
envConfig: parseObject(input.command.env),
templateData,
}),
} as Record<string, string>;
return {
name,
command,
cwd,
env,
};
}
export async function runWorkspaceJobForControl(input: {
actor: ExecutionWorkspaceAgentRef;
issue: ExecutionWorkspaceIssueRef | null;
workspace: RealizedExecutionWorkspace;
command: Record<string, unknown>;
adapterEnv?: Record<string, string>;
recorder?: WorkspaceOperationRecorder | null;
metadata?: Record<string, unknown> | null;
}) {
const resolved = resolveWorkspaceCommandExecution({
command: input.command,
workspace: input.workspace,
agent: input.actor,
issue: input.issue,
adapterEnv: input.adapterEnv ?? {},
});
if (!resolved.command) {
throw new Error(`Workspace job "${resolved.name}" is missing command`);
}
await ensureServerWorkspaceLinksCurrent(resolved.cwd);
return await recordWorkspaceCommandOperation(input.recorder, {
phase: "workspace_provision",
command: resolved.command,
cwd: resolved.cwd,
env: resolved.env,
label: `Workspace job "${resolved.name}"`,
metadata: {
workspaceCommandKind: "job",
workspaceCommandName: resolved.name,
...(input.metadata ?? {}),
},
successMessage: `Completed workspace job "${resolved.name}"\n`,
});
}
function resolveServiceScopeId(input: {
service: Record<string, unknown>;
workspace: RealizedExecutionWorkspace;
@ -1406,6 +1697,21 @@ function resolveServiceScopeId(input: {
return { scopeType: "run" as const, scopeId: input.runId };
}
function looksLikeWorkspaceDevServerCommand(command: string) {
const normalized = command.trim().toLowerCase();
if (!normalized) return false;
return /(?:^|\s)(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?dev(?:\s|$)/.test(normalized);
}
export function resolveWorkspaceRuntimeReadinessTimeoutSec(service: Record<string, unknown>) {
const readiness = parseObject(service.readiness);
const explicitTimeoutSec = asNumber(readiness.timeoutSec, 0);
if (explicitTimeoutSec > 0) {
return Math.max(1, explicitTimeoutSec);
}
return looksLikeWorkspaceDevServerCommand(asString(service.command, "")) ? 90 : 30;
}
async function waitForReadiness(input: {
service: Record<string, unknown>;
url: string | null;
@ -1413,7 +1719,7 @@ async function waitForReadiness(input: {
const readiness = parseObject(input.service.readiness);
const readinessType = asString(readiness.type, "");
if (readinessType !== "http" || !input.url) return;
const timeoutSec = Math.max(1, asNumber(readiness.timeoutSec, 30));
const timeoutSec = resolveWorkspaceRuntimeReadinessTimeoutSec(input.service);
const intervalMs = Math.max(100, asNumber(readiness.intervalMs, 500));
const deadline = Date.now() + timeoutSec * 1000;
let lastError = "service did not become ready";
@ -1735,6 +2041,11 @@ async function startLocalRuntimeService(input: {
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
});
const spawnErrorPromise = new Promise<never>((_, reject) => {
child.once("error", (err) => {
reject(err);
});
});
let stderrExcerpt = "";
let stdoutExcerpt = "";
child.stdout?.on("data", async (chunk) => {
@ -1749,7 +2060,10 @@ async function startLocalRuntimeService(input: {
});
try {
await waitForReadiness({ service: input.service, url });
await Promise.race([
waitForReadiness({ service: input.service, url }),
spawnErrorPromise,
]);
} catch (err) {
terminateChildProcess(child);
throw new Error(
@ -1913,10 +2227,78 @@ function registerRuntimeService(db: Db | undefined, record: RuntimeServiceRecord
}
function readRuntimeServiceEntries(config: Record<string, unknown>) {
const runtime = parseObject(config.workspaceRuntime);
return Array.isArray(runtime.services)
? runtime.services.filter((entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null)
: [];
return listWorkspaceServiceCommandDefinitions(parseObject(config.workspaceRuntime))
.map((command) => command.rawConfig);
}
export function listConfiguredRuntimeServiceEntries(config: Record<string, unknown>) {
return readRuntimeServiceEntries(config);
}
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") {
states[key] = value;
}
}
return states;
}
export function buildWorkspaceRuntimeDesiredStatePatch(input: {
config: Record<string, unknown>;
currentDesiredState: WorkspaceRuntimeDesiredState | null;
currentServiceStates: WorkspaceRuntimeServiceStateMap | null | undefined;
action: "start" | "stop" | "restart";
serviceIndex?: number | null;
}): {
desiredState: WorkspaceRuntimeDesiredState;
serviceStates: WorkspaceRuntimeServiceStateMap | null;
} {
const configuredServices = listConfiguredRuntimeServiceEntries(input.config);
const fallbackState: WorkspaceRuntimeDesiredState = input.currentDesiredState === "running" ? "running" : "stopped";
const nextServiceStates: WorkspaceRuntimeServiceStateMap = {};
for (let index = 0; index < configuredServices.length; index += 1) {
nextServiceStates[String(index)] = input.currentServiceStates?.[String(index)] ?? fallbackState;
}
const nextState: WorkspaceRuntimeDesiredState = input.action === "stop" ? "stopped" : "running";
if (input.serviceIndex === undefined || input.serviceIndex === null) {
for (let index = 0; index < configuredServices.length; index += 1) {
nextServiceStates[String(index)] = nextState;
}
} else if (input.serviceIndex >= 0 && input.serviceIndex < configuredServices.length) {
nextServiceStates[String(input.serviceIndex)] = nextState;
}
const desiredState = Object.values(nextServiceStates).some((state) => state === "running") ? "running" : "stopped";
return {
desiredState,
serviceStates: Object.keys(nextServiceStates).length > 0 ? nextServiceStates : null,
};
}
function selectRuntimeServiceEntries(input: {
config: Record<string, unknown>;
serviceIndex?: number | null;
respectDesiredStates?: boolean;
defaultDesiredState?: WorkspaceRuntimeDesiredState | null;
serviceStates?: WorkspaceRuntimeServiceStateMap | null;
}) {
const entries = listConfiguredRuntimeServiceEntries(input.config);
const states = input.serviceStates ?? readConfiguredServiceStates(input.config);
const fallbackState: WorkspaceRuntimeDesiredState = input.defaultDesiredState === "running" ? "running" : "stopped";
return entries.filter((_, index) => {
if (input.serviceIndex !== undefined && input.serviceIndex !== null) {
return index === input.serviceIndex;
}
if (!input.respectDesiredStates) return true;
return (states[String(index)] ?? fallbackState) === "running";
});
}
export async function ensureRuntimeServicesForRun(input: {
@ -2011,8 +2393,16 @@ export async function startRuntimeServicesForWorkspaceControl(input: {
config: Record<string, unknown>;
adapterEnv: Record<string, string>;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
serviceIndex?: number | null;
respectDesiredStates?: boolean;
}): Promise<RuntimeServiceRef[]> {
const rawServices = readRuntimeServiceEntries(input.config);
const rawServices = selectRuntimeServiceEntries({
config: input.config,
serviceIndex: input.serviceIndex,
respectDesiredStates: input.respectDesiredStates,
defaultDesiredState: input.config.desiredState === "running" ? "running" : "stopped",
serviceStates: readConfiguredServiceStates(input.config),
});
const refs: RuntimeServiceRef[] = [];
const invocationId = input.invocationId ?? randomUUID();
@ -2102,10 +2492,12 @@ export async function stopRuntimeServicesForExecutionWorkspace(input: {
db?: Db;
executionWorkspaceId: string;
workspaceCwd?: string | null;
runtimeServiceId?: string | null;
}) {
const normalizedWorkspaceCwd = input.workspaceCwd ? path.resolve(input.workspaceCwd) : null;
const matchingServiceIds = Array.from(runtimeServicesById.values())
.filter((record) => {
if (input.runtimeServiceId) return record.id === input.runtimeServiceId;
if (record.executionWorkspaceId === input.executionWorkspaceId) return true;
if (!normalizedWorkspaceCwd || !record.cwd) return false;
const resolvedCwd = path.resolve(record.cwd);
@ -2121,19 +2513,37 @@ export async function stopRuntimeServicesForExecutionWorkspace(input: {
}
if (input.db) {
await markPersistedRuntimeServicesStoppedForExecutionWorkspace({
db: input.db,
executionWorkspaceId: input.executionWorkspaceId,
});
if (input.runtimeServiceId) {
const now = new Date();
await input.db
.update(workspaceRuntimeServices)
.set({
status: "stopped",
healthStatus: "unknown",
stoppedAt: now,
lastUsedAt: now,
updatedAt: now,
})
.where(eq(workspaceRuntimeServices.id, input.runtimeServiceId));
} else {
await markPersistedRuntimeServicesStoppedForExecutionWorkspace({
db: input.db,
executionWorkspaceId: input.executionWorkspaceId,
});
}
}
}
export async function stopRuntimeServicesForProjectWorkspace(input: {
db?: Db;
projectWorkspaceId: string;
runtimeServiceId?: string | null;
}) {
const matchingServiceIds = Array.from(runtimeServicesById.values())
.filter((record) => record.projectWorkspaceId === input.projectWorkspaceId && record.scopeType === "project_workspace")
.filter((record) => {
if (input.runtimeServiceId) return record.id === input.runtimeServiceId;
return record.projectWorkspaceId === input.projectWorkspaceId && record.scopeType === "project_workspace";
})
.map((record) => record.id);
for (const serviceId of matchingServiceIds) {
@ -2152,11 +2562,13 @@ export async function stopRuntimeServicesForProjectWorkspace(input: {
updatedAt: now,
})
.where(
and(
eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId),
eq(workspaceRuntimeServices.scopeType, "project_workspace"),
inArray(workspaceRuntimeServices.status, ["starting", "running"]),
),
input.runtimeServiceId
? eq(workspaceRuntimeServices.id, input.runtimeServiceId)
: and(
eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId),
eq(workspaceRuntimeServices.scopeType, "project_workspace"),
inArray(workspaceRuntimeServices.status, ["starting", "running"]),
),
);
}
}
@ -2292,6 +2704,7 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) {
const projectWorkspaceRows = await db
.select()
.from(projectWorkspaces);
const projectWorkspaceRowsById = new Map(projectWorkspaceRows.map((row) => [row.id, row] as const));
for (const row of projectWorkspaceRows) {
const runtimeConfig = readProjectWorkspaceRuntimeConfig((row.metadata as Record<string, unknown> | null) ?? null);
@ -2316,8 +2729,13 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) {
warnings: [],
created: false,
},
config: { workspaceRuntime: runtimeConfig.workspaceRuntime },
config: {
workspaceRuntime: runtimeConfig.workspaceRuntime,
desiredState: runtimeConfig.desiredState,
serviceStates: runtimeConfig.serviceStates ?? null,
},
adapterEnv: {},
respectDesiredStates: true,
});
if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length;
} catch {
@ -2332,7 +2750,13 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) {
for (const row of executionWorkspaceRows) {
const config = readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null);
if (config?.desiredState !== "running" || !config.workspaceRuntime || !row.cwd) continue;
const inheritedRuntimeConfig = row.projectWorkspaceId
? readProjectWorkspaceRuntimeConfig(
(projectWorkspaceRowsById.get(row.projectWorkspaceId)?.metadata as Record<string, unknown> | null) ?? null,
)?.workspaceRuntime ?? null
: null;
const effectiveRuntimeConfig = config?.workspaceRuntime ?? inheritedRuntimeConfig;
if (config?.desiredState !== "running" || !effectiveRuntimeConfig || !row.cwd) continue;
try {
const refs = await startRuntimeServicesForWorkspaceControl({
@ -2360,8 +2784,13 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) {
created: false,
},
executionWorkspaceId: row.id,
config: { workspaceRuntime: config.workspaceRuntime },
config: {
workspaceRuntime: effectiveRuntimeConfig,
desiredState: config.desiredState,
serviceStates: config.serviceStates ?? null,
},
adapterEnv: {},
respectDesiredStates: true,
});
if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length;
} catch {