From f515f2aa12db1cbb1e6b690c76ba7582b7117cd0 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 4 Apr 2026 13:15:46 -0500 Subject: [PATCH] Fix workspace runtime state reconciliation Co-Authored-By: Paperclip --- .../execution-workspaces-service.test.ts | 134 ++++++++++++++++++ .../src/__tests__/workspace-runtime.test.ts | 93 ++++++++++++ server/src/services/execution-workspaces.ts | 66 +++++++-- .../src/services/local-service-supervisor.ts | 31 +++- server/src/services/projects.ts | 6 +- .../services/workspace-runtime-read-model.ts | 96 +++++++++++++ server/src/services/workspace-runtime.ts | 103 ++++++++------ ui/src/pages/ExecutionWorkspaceDetail.tsx | 6 +- ui/src/pages/ProjectWorkspaceDetail.tsx | 6 +- 9 files changed, 477 insertions(+), 64 deletions(-) create mode 100644 server/src/services/workspace-runtime-read-model.ts diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index d4a50bdc..ca6b38d5 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -12,6 +12,7 @@ import { issues, projectWorkspaces, projects, + workspaceRuntimeServices, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -133,6 +134,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { afterEach(async () => { await db.delete(issues); + await db.delete(workspaceRuntimeServices); await db.delete(executionWorkspaces); await db.delete(projectWorkspaces); await db.delete(projects); @@ -322,4 +324,136 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { "git_branch_delete", ])); }, 20_000); + + it("shows inherited shared project runtime services on shared execution workspaces without duplicating old history", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + const olderServiceId = randomUUID(); + const currentServiceId = randomUUID(); + const reuseKey = `project_workspace:${projectWorkspaceId}:paperclip-dev`; + const startedAt = new Date("2026-04-04T17:00:00.000Z"); + const stoppedAt = new Date("2026-04-04T17:05:00.000Z"); + const runningAt = new Date("2026-04-04T17:10:00.000Z"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "PAP", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspaces", + status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + }, + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + sourceType: "local_path", + isPrimary: true, + cwd: "/tmp/paperclip-primary", + metadata: { + runtimeConfig: { + desiredState: "running", + workspaceRuntime: { + services: [{ name: "paperclip-dev", command: "pnpm dev" }], + }, + }, + }, + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Shared workspace", + status: "active", + providerType: "local_fs", + cwd: "/tmp/paperclip-primary", + }); + await db.insert(workspaceRuntimeServices).values([ + { + id: olderServiceId, + companyId, + projectId, + projectWorkspaceId, + executionWorkspaceId: null, + issueId: null, + scopeType: "project_workspace", + scopeId: projectWorkspaceId, + serviceName: "paperclip-dev", + status: "stopped", + lifecycle: "shared", + reuseKey, + command: "pnpm dev", + cwd: "/tmp/paperclip-primary", + port: 49195, + url: "http://127.0.0.1:49195", + provider: "local_process", + providerRef: "11111", + ownerAgentId: null, + startedByRunId: null, + lastUsedAt: stoppedAt, + startedAt, + stoppedAt, + stopPolicy: { type: "manual" }, + healthStatus: "unknown", + createdAt: startedAt, + updatedAt: stoppedAt, + }, + { + id: currentServiceId, + companyId, + projectId, + projectWorkspaceId, + executionWorkspaceId: null, + issueId: null, + scopeType: "project_workspace", + scopeId: projectWorkspaceId, + serviceName: "paperclip-dev", + status: "running", + lifecycle: "shared", + reuseKey, + command: "pnpm dev", + cwd: "/tmp/paperclip-primary", + port: 49222, + url: "http://127.0.0.1:49222", + provider: "local_process", + providerRef: "22222", + ownerAgentId: null, + startedByRunId: null, + lastUsedAt: runningAt, + startedAt: runningAt, + stoppedAt: null, + stopPolicy: { type: "manual" }, + healthStatus: "healthy", + createdAt: runningAt, + updatedAt: runningAt, + }, + ]); + + const workspace = await svc.getById(executionWorkspaceId); + const listed = await svc.list(companyId, { projectId }); + + expect(workspace?.runtimeServices).toHaveLength(1); + expect(workspace?.runtimeServices?.[0]).toMatchObject({ + id: currentServiceId, + status: "running", + projectWorkspaceId, + executionWorkspaceId: null, + url: "http://127.0.0.1:49222", + }); + expect(listed[0]?.runtimeServices).toHaveLength(1); + expect(listed[0]?.runtimeServices?.[0]?.id).toBe(currentServiceId); + }); }); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index f158a5e9..472f58b0 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -13,6 +13,7 @@ import { createDb, executionWorkspaces, heartbeatRuns, + projectWorkspaces, projects, workspaceRuntimeServices, } from "@paperclipai/db"; @@ -30,6 +31,7 @@ import { stopRuntimeServicesForExecutionWorkspace, type RealizedExecutionWorkspace, } from "../services/workspace-runtime.ts"; +import { writeLocalServiceRegistryRecord } from "../services/local-service-supervisor.ts"; import { resolvePaperclipConfigPath } from "../paths.ts"; import type { WorkspaceOperation } from "@paperclipai/shared"; import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts"; @@ -1416,6 +1418,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { afterEach(async () => { await db.delete(workspaceRuntimeServices); await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); await db.delete(projects); await db.delete(heartbeatRuns); await db.delete(agents); @@ -1530,6 +1533,96 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { await expect(fetch(service!.url!)).rejects.toThrow(); }); + it("marks persisted local services stopped when the registry pid is stale", async () => { + const companyId = randomUUID(); + const runtimeServiceId = randomUUID(); + const startedAt = new Date("2026-04-04T17:00:00.000Z"); + const updatedAt = new Date("2026-04-04T17:10:00.000Z"); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Runtime reconcile test", + status: "in_progress", + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + sourceType: "local_path", + cwd: "/tmp/paperclip-primary", + isPrimary: true, + }); + await db.insert(workspaceRuntimeServices).values({ + id: runtimeServiceId, + companyId, + projectId, + projectWorkspaceId, + executionWorkspaceId: null, + issueId: null, + scopeType: "project_workspace", + scopeId: projectWorkspaceId, + serviceName: "paperclip-dev", + status: "running", + lifecycle: "shared", + reuseKey: `project_workspace:${projectWorkspaceId}:paperclip-dev`, + command: "pnpm dev", + cwd: "/tmp/paperclip-primary", + port: 49195, + url: "http://127.0.0.1:49195", + provider: "local_process", + providerRef: "999999", + ownerAgentId: null, + startedByRunId: null, + lastUsedAt: updatedAt, + startedAt, + stoppedAt: null, + stopPolicy: { type: "manual" }, + healthStatus: "healthy", + createdAt: startedAt, + updatedAt, + }); + await writeLocalServiceRegistryRecord({ + version: 1, + serviceKey: "workspace-runtime-paperclip-dev-stale", + profileKind: "workspace-runtime", + serviceName: "paperclip-dev", + command: "pnpm dev", + cwd: "/tmp/paperclip-primary", + envFingerprint: "fingerprint", + port: 49195, + url: "http://127.0.0.1:49195", + pid: 999999, + processGroupId: 999999, + provider: "local_process", + runtimeServiceId, + reuseKey: `project_workspace:${projectWorkspaceId}:paperclip-dev`, + startedAt: startedAt.toISOString(), + lastSeenAt: updatedAt.toISOString(), + metadata: null, + }); + + const result = await reconcilePersistedRuntimeServicesOnStartup(db); + + expect(result).toMatchObject({ reconciled: 1, adopted: 0, stopped: 1 }); + const persisted = await db + .select() + .from(workspaceRuntimeServices) + .where(eq(workspaceRuntimeServices.id, runtimeServiceId)) + .then((rows) => rows[0] ?? null); + expect(persisted?.status).toBe("stopped"); + expect(persisted?.stoppedAt).not.toBeNull(); + }); + it("persists controlled execution workspace stops as stopped", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-")); const companyId = randomUUID(); diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index a1d3b41d..58a43210 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -14,6 +14,10 @@ import type { WorkspaceRuntimeService, } from "@paperclipai/shared"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; +import { + listCurrentRuntimeServicesForExecutionWorkspaces, + listCurrentRuntimeServicesForProjectWorkspaces, +} from "./workspace-runtime-read-model.js"; type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect; type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect; @@ -317,6 +321,41 @@ function toExecutionWorkspace( }; } +function usesInheritedProjectRuntimeServices(row: ExecutionWorkspaceRow) { + if (row.mode !== "shared_workspace" || !row.projectWorkspaceId) return false; + return !readExecutionWorkspaceConfig((row.metadata as Record | null) ?? null)?.workspaceRuntime; +} + +async function loadEffectiveRuntimeServicesByExecutionWorkspace( + db: Db, + companyId: string, + rows: ExecutionWorkspaceRow[], +) { + const executionRuntimeServices = await listCurrentRuntimeServicesForExecutionWorkspaces( + db, + companyId, + rows.map((row) => row.id), + ); + const projectWorkspaceIds = rows + .filter((row) => usesInheritedProjectRuntimeServices(row)) + .map((row) => row.projectWorkspaceId) + .filter((value): value is string => Boolean(value)); + const projectRuntimeServices = await listCurrentRuntimeServicesForProjectWorkspaces( + db, + companyId, + [...new Set(projectWorkspaceIds)], + ); + + return new Map( + rows.map((row) => [ + row.id, + usesInheritedProjectRuntimeServices(row) + ? (projectRuntimeServices.get(row.projectWorkspaceId!) ?? []) + : (executionRuntimeServices.get(row.id) ?? []), + ]), + ); +} + export function executionWorkspaceService(db: Db) { return { list: async (companyId: string, filters?: { @@ -346,7 +385,13 @@ export function executionWorkspaceService(db: Db) { .from(executionWorkspaces) .where(and(...conditions)) .orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt)); - return rows.map((row) => toExecutionWorkspace(row)); + const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, companyId, rows); + return rows.map((row) => + toExecutionWorkspace( + row, + (runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService), + ), + ); }, getById: async (id: string) => { @@ -356,12 +401,11 @@ export function executionWorkspaceService(db: Db) { .where(eq(executionWorkspaces.id, id)) .then((rows) => rows[0] ?? null); if (!row) return null; - const runtimeServiceRows = await db - .select() - .from(workspaceRuntimeServices) - .where(eq(workspaceRuntimeServices.executionWorkspaceId, row.id)) - .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); - return toExecutionWorkspace(row, runtimeServiceRows.map(toRuntimeService)); + const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, row.companyId, [row]); + return toExecutionWorkspace( + row, + (runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService), + ); }, getCloseReadiness: async (id: string): Promise => { @@ -372,12 +416,8 @@ export function executionWorkspaceService(db: Db) { .then((rows) => rows[0] ?? null); if (!workspace) return null; - const runtimeServiceRows = await db - .select() - .from(workspaceRuntimeServices) - .where(eq(workspaceRuntimeServices.executionWorkspaceId, workspace.id)) - .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); - const runtimeServices = runtimeServiceRows.map(toRuntimeService); + const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, workspace.companyId, [workspace]); + const runtimeServices = (runtimeServicesByWorkspaceId.get(workspace.id) ?? []).map(toRuntimeService); const linkedIssues = await db .select({ diff --git a/server/src/services/local-service-supervisor.ts b/server/src/services/local-service-supervisor.ts index 68dbbdc8..eac87732 100644 --- a/server/src/services/local-service-supervisor.ts +++ b/server/src/services/local-service-supervisor.ts @@ -184,7 +184,31 @@ export async function findLocalServiceRegistryRecordByRuntimeServiceId(input: { const records = await listLocalServiceRegistryRecords( input.profileKind ? { profileKind: input.profileKind } : undefined, ); - return records.find((record) => record.runtimeServiceId === input.runtimeServiceId) ?? null; + const record = records.find((entry) => entry.runtimeServiceId === input.runtimeServiceId) ?? null; + if (!record) return null; + + let candidate = record; + if (!isPidAlive(candidate.pid)) { + const ownerPid = candidate.port ? await readLocalServicePortOwner(candidate.port) : null; + if (!ownerPid) { + await removeLocalServiceRegistryRecord(candidate.serviceKey); + return null; + } + candidate = { + ...candidate, + pid: ownerPid, + processGroupId: candidate.processGroupId && isPidAlive(candidate.processGroupId) ? candidate.processGroupId : ownerPid, + lastSeenAt: new Date().toISOString(), + }; + await writeLocalServiceRegistryRecord(candidate); + } + + if (!(await isLikelyMatchingCommand(candidate))) { + await removeLocalServiceRegistryRecord(record.serviceKey); + return null; + } + + return candidate; } export function isPidAlive(pid: number) { @@ -203,7 +227,10 @@ async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) { const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]); const commandLine = stdout.trim(); if (!commandLine) return false; - return commandLine.includes(record.command) || commandLine.includes(record.serviceName); + const normalize = (value: string) => value.replace(/["']/g, "").replace(/\s+/g, " ").trim(); + const normalizedCommandLine = normalize(commandLine); + const normalizedRecordedCommand = normalize(record.command); + return normalizedCommandLine.includes(normalizedRecordedCommand) || normalizedCommandLine.includes(record.serviceName); } catch { return true; } diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index db786478..f653ea1a 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -14,7 +14,7 @@ import { type ProjectWorkspace, type WorkspaceRuntimeService, } from "@paperclipai/shared"; -import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js"; +import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runtime-read-model.js"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; import { resolveManagedProjectWorkspaceDir } from "../home-paths.js"; @@ -223,7 +223,7 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise workspace.id), @@ -541,7 +541,7 @@ export function projectService(db: Db) { .where(eq(projectWorkspaces.projectId, projectId)) .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); if (rows.length === 0) return []; - const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces( + const runtimeServicesByWorkspaceId = await listCurrentRuntimeServicesForProjectWorkspaces( db, rows[0]!.companyId, rows.map((workspace) => workspace.id), diff --git a/server/src/services/workspace-runtime-read-model.ts b/server/src/services/workspace-runtime-read-model.ts new file mode 100644 index 00000000..dba6190d --- /dev/null +++ b/server/src/services/workspace-runtime-read-model.ts @@ -0,0 +1,96 @@ +import type { Db } from "@paperclipai/db"; +import { workspaceRuntimeServices } from "@paperclipai/db"; +import { and, desc, eq, inArray } from "drizzle-orm"; + +type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect; + +function runtimeServiceIdentityKey(row: WorkspaceRuntimeServiceRow) { + if (row.reuseKey) return row.reuseKey; + return [ + row.scopeType, + row.scopeId ?? "", + row.projectWorkspaceId ?? "", + row.executionWorkspaceId ?? "", + row.serviceName, + row.command ?? "", + row.cwd ?? "", + ].join(":"); +} + +export function selectCurrentRuntimeServiceRows(rows: WorkspaceRuntimeServiceRow[]) { + const current = new Map(); + for (const row of rows) { + const identity = runtimeServiceIdentityKey(row); + if (!current.has(identity)) current.set(identity, row); + } + return [...current.values()]; +} + +export async function listCurrentRuntimeServicesForProjectWorkspaces( + db: Db, + companyId: string, + projectWorkspaceIds: string[], +) { + if (projectWorkspaceIds.length === 0) return new Map(); + + const rows = await db + .select() + .from(workspaceRuntimeServices) + .where( + and( + eq(workspaceRuntimeServices.companyId, companyId), + inArray(workspaceRuntimeServices.projectWorkspaceId, projectWorkspaceIds), + eq(workspaceRuntimeServices.scopeType, "project_workspace"), + ), + ) + .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); + + const grouped = new Map(); + for (const row of rows) { + if (!row.projectWorkspaceId) continue; + const existing = grouped.get(row.projectWorkspaceId) ?? []; + existing.push(row); + grouped.set(row.projectWorkspaceId, existing); + } + + return new Map( + Array.from(grouped.entries()).map(([workspaceId, workspaceRows]) => [ + workspaceId, + selectCurrentRuntimeServiceRows(workspaceRows), + ]), + ); +} + +export async function listCurrentRuntimeServicesForExecutionWorkspaces( + db: Db, + companyId: string, + executionWorkspaceIds: string[], +) { + if (executionWorkspaceIds.length === 0) return new Map(); + + const rows = await db + .select() + .from(workspaceRuntimeServices) + .where( + and( + eq(workspaceRuntimeServices.companyId, companyId), + inArray(workspaceRuntimeServices.executionWorkspaceId, executionWorkspaceIds), + ), + ) + .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); + + const grouped = new Map(); + for (const row of rows) { + if (!row.executionWorkspaceId) continue; + const existing = grouped.get(row.executionWorkspaceId) ?? []; + existing.push(row); + grouped.set(row.executionWorkspaceId, existing); + } + + return new Map( + Array.from(grouped.entries()).map(([workspaceId, workspaceRows]) => [ + workspaceId, + selectCurrentRuntimeServiceRows(workspaceRows), + ]), + ); +} diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index a100242e..44040311 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -1081,6 +1081,16 @@ async function waitForReadiness(input: { throw new Error(`Readiness check failed for ${input.url}: ${lastError}`); } +async function isRuntimeServiceUrlHealthy(url: string | null) { + if (!url) return true; + try { + const response = await fetch(url, { signal: AbortSignal.timeout(2_000) }); + return response.ok; + } catch { + return false; + } +} + function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeof workspaceRuntimeServices.$inferInsert { return { id: record.id, @@ -1847,50 +1857,55 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) { profileKind: "workspace-runtime", }); if (adoptedRecord) { - const record: RuntimeServiceRecord = { - id: row.id, - companyId: row.companyId, - projectId: row.projectId ?? null, - projectWorkspaceId: row.projectWorkspaceId ?? null, - executionWorkspaceId: row.executionWorkspaceId ?? null, - issueId: row.issueId ?? null, - serviceName: row.serviceName, - status: "running", - lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"], - scopeType: row.scopeType as RuntimeServiceRecord["scopeType"], - scopeId: row.scopeId ?? null, - reuseKey: row.reuseKey ?? null, - command: row.command ?? null, - cwd: row.cwd ?? null, - port: adoptedRecord.port ?? row.port ?? null, - url: adoptedRecord.url ?? row.url ?? null, - provider: "local_process", - providerRef: String(adoptedRecord.pid), - ownerAgentId: row.ownerAgentId ?? null, - startedByRunId: row.startedByRunId ?? null, - lastUsedAt: new Date().toISOString(), - startedAt: row.startedAt.toISOString(), - stoppedAt: null, - stopPolicy: (row.stopPolicy as Record | null) ?? null, - healthStatus: "healthy", - reused: true, - db, - child: null, - leaseRunIds: new Set(), - idleTimer: null, - envFingerprint: row.reuseKey ?? "", - serviceKey: adoptedRecord.serviceKey, - profileKind: "workspace-runtime", - processGroupId: adoptedRecord.processGroupId ?? null, - }; - registerRuntimeService(db, record); - await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, { - runtimeServiceId: row.id, - lastSeenAt: record.lastUsedAt, - }); - await persistRuntimeServiceRecord(db, record); - adopted += 1; - continue; + const adoptedUrl = adoptedRecord.url ?? row.url ?? null; + if (!(await isRuntimeServiceUrlHealthy(adoptedUrl))) { + await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey); + } else { + const record: RuntimeServiceRecord = { + id: row.id, + companyId: row.companyId, + projectId: row.projectId ?? null, + projectWorkspaceId: row.projectWorkspaceId ?? null, + executionWorkspaceId: row.executionWorkspaceId ?? null, + issueId: row.issueId ?? null, + serviceName: row.serviceName, + status: "running", + lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"], + scopeType: row.scopeType as RuntimeServiceRecord["scopeType"], + scopeId: row.scopeId ?? null, + reuseKey: row.reuseKey ?? null, + command: row.command ?? null, + cwd: row.cwd ?? null, + port: adoptedRecord.port ?? row.port ?? null, + url: adoptedRecord.url ?? row.url ?? null, + provider: "local_process", + providerRef: String(adoptedRecord.pid), + ownerAgentId: row.ownerAgentId ?? null, + startedByRunId: row.startedByRunId ?? null, + lastUsedAt: new Date().toISOString(), + startedAt: row.startedAt.toISOString(), + stoppedAt: null, + stopPolicy: (row.stopPolicy as Record | null) ?? null, + healthStatus: "healthy", + reused: true, + db, + child: null, + leaseRunIds: new Set(), + idleTimer: null, + envFingerprint: row.reuseKey ?? "", + serviceKey: adoptedRecord.serviceKey, + profileKind: "workspace-runtime", + processGroupId: adoptedRecord.processGroupId ?? null, + }; + registerRuntimeService(db, record); + await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, { + runtimeServiceId: row.id, + lastSeenAt: record.lastUsedAt, + }); + await persistRuntimeServiceRecord(db, record); + adopted += 1; + continue; + } } const now = new Date(); diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index c0db7f88..7fdac3e9 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -43,6 +43,10 @@ function readText(value: string | null | undefined) { return value ?? ""; } +function hasActiveRuntimeServices(workspace: ExecutionWorkspace | null | undefined) { + return (workspace?.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running"); +} + function formatJson(value: Record | null | undefined) { if (!value || Object.keys(value).length === 0) return ""; return JSON.stringify(value, null, 2); @@ -709,7 +713,7 @@ export function ExecutionWorkspaceDetail() { variant="outline" size="sm" className="w-full sm:w-auto" - disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0} + disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)} onClick={() => controlRuntimeServices.mutate("stop")} > Stop diff --git a/ui/src/pages/ProjectWorkspaceDetail.tsx b/ui/src/pages/ProjectWorkspaceDetail.tsx index 5831ec82..a5fe8ac1 100644 --- a/ui/src/pages/ProjectWorkspaceDetail.tsx +++ b/ui/src/pages/ProjectWorkspaceDetail.tsx @@ -61,6 +61,10 @@ function readText(value: string | null | undefined) { return value ?? ""; } +function hasActiveRuntimeServices(workspace: ProjectWorkspace | null | undefined) { + return (workspace?.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running"); +} + function formatJson(value: Record | null | undefined) { if (!value || Object.keys(value).length === 0) return ""; return JSON.stringify(value, null, 2); @@ -624,7 +628,7 @@ export function ProjectWorkspaceDetail() { variant="outline" size="sm" className="w-full sm:w-auto" - disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0} + disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)} onClick={() => controlRuntimeServices.mutate("stop")} > Stop