Fix workspace runtime state reconciliation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-04 13:15:46 -05:00
parent 5a9a2a9112
commit f515f2aa12
9 changed files with 477 additions and 64 deletions

View file

@ -12,6 +12,7 @@ import {
issues, issues,
projectWorkspaces, projectWorkspaces,
projects, projects,
workspaceRuntimeServices,
} from "@paperclipai/db"; } from "@paperclipai/db";
import { import {
getEmbeddedPostgresTestSupport, getEmbeddedPostgresTestSupport,
@ -133,6 +134,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
afterEach(async () => { afterEach(async () => {
await db.delete(issues); await db.delete(issues);
await db.delete(workspaceRuntimeServices);
await db.delete(executionWorkspaces); await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces); await db.delete(projectWorkspaces);
await db.delete(projects); await db.delete(projects);
@ -322,4 +324,136 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
"git_branch_delete", "git_branch_delete",
])); ]));
}, 20_000); }, 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);
});
}); });

View file

@ -13,6 +13,7 @@ import {
createDb, createDb,
executionWorkspaces, executionWorkspaces,
heartbeatRuns, heartbeatRuns,
projectWorkspaces,
projects, projects,
workspaceRuntimeServices, workspaceRuntimeServices,
} from "@paperclipai/db"; } from "@paperclipai/db";
@ -30,6 +31,7 @@ import {
stopRuntimeServicesForExecutionWorkspace, stopRuntimeServicesForExecutionWorkspace,
type RealizedExecutionWorkspace, type RealizedExecutionWorkspace,
} from "../services/workspace-runtime.ts"; } from "../services/workspace-runtime.ts";
import { writeLocalServiceRegistryRecord } from "../services/local-service-supervisor.ts";
import { resolvePaperclipConfigPath } from "../paths.ts"; import { resolvePaperclipConfigPath } from "../paths.ts";
import type { WorkspaceOperation } from "@paperclipai/shared"; import type { WorkspaceOperation } from "@paperclipai/shared";
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts"; import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
@ -1416,6 +1418,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
afterEach(async () => { afterEach(async () => {
await db.delete(workspaceRuntimeServices); await db.delete(workspaceRuntimeServices);
await db.delete(executionWorkspaces); await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects); await db.delete(projects);
await db.delete(heartbeatRuns); await db.delete(heartbeatRuns);
await db.delete(agents); await db.delete(agents);
@ -1530,6 +1533,96 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
await expect(fetch(service!.url!)).rejects.toThrow(); 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 () => { it("persists controlled execution workspace stops as stopped", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-")); const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-"));
const companyId = randomUUID(); const companyId = randomUUID();

View file

@ -14,6 +14,10 @@ import type {
WorkspaceRuntimeService, WorkspaceRuntimeService,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
import {
listCurrentRuntimeServicesForExecutionWorkspaces,
listCurrentRuntimeServicesForProjectWorkspaces,
} from "./workspace-runtime-read-model.js";
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect; type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$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<string, unknown> | 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) { export function executionWorkspaceService(db: Db) {
return { return {
list: async (companyId: string, filters?: { list: async (companyId: string, filters?: {
@ -346,7 +385,13 @@ export function executionWorkspaceService(db: Db) {
.from(executionWorkspaces) .from(executionWorkspaces)
.where(and(...conditions)) .where(and(...conditions))
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt)); .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) => { getById: async (id: string) => {
@ -356,12 +401,11 @@ export function executionWorkspaceService(db: Db) {
.where(eq(executionWorkspaces.id, id)) .where(eq(executionWorkspaces.id, id))
.then((rows) => rows[0] ?? null); .then((rows) => rows[0] ?? null);
if (!row) return null; if (!row) return null;
const runtimeServiceRows = await db const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, row.companyId, [row]);
.select() return toExecutionWorkspace(
.from(workspaceRuntimeServices) row,
.where(eq(workspaceRuntimeServices.executionWorkspaceId, row.id)) (runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService),
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); );
return toExecutionWorkspace(row, runtimeServiceRows.map(toRuntimeService));
}, },
getCloseReadiness: async (id: string): Promise<ExecutionWorkspaceCloseReadiness | null> => { getCloseReadiness: async (id: string): Promise<ExecutionWorkspaceCloseReadiness | null> => {
@ -372,12 +416,8 @@ export function executionWorkspaceService(db: Db) {
.then((rows) => rows[0] ?? null); .then((rows) => rows[0] ?? null);
if (!workspace) return null; if (!workspace) return null;
const runtimeServiceRows = await db const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, workspace.companyId, [workspace]);
.select() const runtimeServices = (runtimeServicesByWorkspaceId.get(workspace.id) ?? []).map(toRuntimeService);
.from(workspaceRuntimeServices)
.where(eq(workspaceRuntimeServices.executionWorkspaceId, workspace.id))
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
const runtimeServices = runtimeServiceRows.map(toRuntimeService);
const linkedIssues = await db const linkedIssues = await db
.select({ .select({

View file

@ -184,7 +184,31 @@ export async function findLocalServiceRegistryRecordByRuntimeServiceId(input: {
const records = await listLocalServiceRegistryRecords( const records = await listLocalServiceRegistryRecords(
input.profileKind ? { profileKind: input.profileKind } : undefined, 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) { 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 { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]);
const commandLine = stdout.trim(); const commandLine = stdout.trim();
if (!commandLine) return false; 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 { } catch {
return true; return true;
} }

View file

@ -14,7 +14,7 @@ import {
type ProjectWorkspace, type ProjectWorkspace,
type WorkspaceRuntimeService, type WorkspaceRuntimeService,
} from "@paperclipai/shared"; } 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 { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js"; import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
@ -223,7 +223,7 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
.from(projectWorkspaces) .from(projectWorkspaces)
.where(inArray(projectWorkspaces.projectId, projectIds)) .where(inArray(projectWorkspaces.projectId, projectIds))
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces( const runtimeServicesByWorkspaceId = await listCurrentRuntimeServicesForProjectWorkspaces(
db, db,
rows[0]!.companyId, rows[0]!.companyId,
workspaceRows.map((workspace) => workspace.id), workspaceRows.map((workspace) => workspace.id),
@ -541,7 +541,7 @@ export function projectService(db: Db) {
.where(eq(projectWorkspaces.projectId, projectId)) .where(eq(projectWorkspaces.projectId, projectId))
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
if (rows.length === 0) return []; if (rows.length === 0) return [];
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces( const runtimeServicesByWorkspaceId = await listCurrentRuntimeServicesForProjectWorkspaces(
db, db,
rows[0]!.companyId, rows[0]!.companyId,
rows.map((workspace) => workspace.id), rows.map((workspace) => workspace.id),

View file

@ -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<string, WorkspaceRuntimeServiceRow>();
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<string, WorkspaceRuntimeServiceRow[]>();
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<string, WorkspaceRuntimeServiceRow[]>();
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<string, WorkspaceRuntimeServiceRow[]>();
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<string, WorkspaceRuntimeServiceRow[]>();
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),
]),
);
}

View file

@ -1081,6 +1081,16 @@ async function waitForReadiness(input: {
throw new Error(`Readiness check failed for ${input.url}: ${lastError}`); 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 { function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeof workspaceRuntimeServices.$inferInsert {
return { return {
id: record.id, id: record.id,
@ -1847,50 +1857,55 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) {
profileKind: "workspace-runtime", profileKind: "workspace-runtime",
}); });
if (adoptedRecord) { if (adoptedRecord) {
const record: RuntimeServiceRecord = { const adoptedUrl = adoptedRecord.url ?? row.url ?? null;
id: row.id, if (!(await isRuntimeServiceUrlHealthy(adoptedUrl))) {
companyId: row.companyId, await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey);
projectId: row.projectId ?? null, } else {
projectWorkspaceId: row.projectWorkspaceId ?? null, const record: RuntimeServiceRecord = {
executionWorkspaceId: row.executionWorkspaceId ?? null, id: row.id,
issueId: row.issueId ?? null, companyId: row.companyId,
serviceName: row.serviceName, projectId: row.projectId ?? null,
status: "running", projectWorkspaceId: row.projectWorkspaceId ?? null,
lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"], executionWorkspaceId: row.executionWorkspaceId ?? null,
scopeType: row.scopeType as RuntimeServiceRecord["scopeType"], issueId: row.issueId ?? null,
scopeId: row.scopeId ?? null, serviceName: row.serviceName,
reuseKey: row.reuseKey ?? null, status: "running",
command: row.command ?? null, lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"],
cwd: row.cwd ?? null, scopeType: row.scopeType as RuntimeServiceRecord["scopeType"],
port: adoptedRecord.port ?? row.port ?? null, scopeId: row.scopeId ?? null,
url: adoptedRecord.url ?? row.url ?? null, reuseKey: row.reuseKey ?? null,
provider: "local_process", command: row.command ?? null,
providerRef: String(adoptedRecord.pid), cwd: row.cwd ?? null,
ownerAgentId: row.ownerAgentId ?? null, port: adoptedRecord.port ?? row.port ?? null,
startedByRunId: row.startedByRunId ?? null, url: adoptedRecord.url ?? row.url ?? null,
lastUsedAt: new Date().toISOString(), provider: "local_process",
startedAt: row.startedAt.toISOString(), providerRef: String(adoptedRecord.pid),
stoppedAt: null, ownerAgentId: row.ownerAgentId ?? null,
stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null, startedByRunId: row.startedByRunId ?? null,
healthStatus: "healthy", lastUsedAt: new Date().toISOString(),
reused: true, startedAt: row.startedAt.toISOString(),
db, stoppedAt: null,
child: null, stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null,
leaseRunIds: new Set(), healthStatus: "healthy",
idleTimer: null, reused: true,
envFingerprint: row.reuseKey ?? "", db,
serviceKey: adoptedRecord.serviceKey, child: null,
profileKind: "workspace-runtime", leaseRunIds: new Set(),
processGroupId: adoptedRecord.processGroupId ?? null, idleTimer: null,
}; envFingerprint: row.reuseKey ?? "",
registerRuntimeService(db, record); serviceKey: adoptedRecord.serviceKey,
await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, { profileKind: "workspace-runtime",
runtimeServiceId: row.id, processGroupId: adoptedRecord.processGroupId ?? null,
lastSeenAt: record.lastUsedAt, };
}); registerRuntimeService(db, record);
await persistRuntimeServiceRecord(db, record); await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, {
adopted += 1; runtimeServiceId: row.id,
continue; lastSeenAt: record.lastUsedAt,
});
await persistRuntimeServiceRecord(db, record);
adopted += 1;
continue;
}
} }
const now = new Date(); const now = new Date();

View file

@ -43,6 +43,10 @@ function readText(value: string | null | undefined) {
return value ?? ""; return value ?? "";
} }
function hasActiveRuntimeServices(workspace: ExecutionWorkspace | null | undefined) {
return (workspace?.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running");
}
function formatJson(value: Record<string, unknown> | null | undefined) { function formatJson(value: Record<string, unknown> | null | undefined) {
if (!value || Object.keys(value).length === 0) return ""; if (!value || Object.keys(value).length === 0) return "";
return JSON.stringify(value, null, 2); return JSON.stringify(value, null, 2);
@ -709,7 +713,7 @@ export function ExecutionWorkspaceDetail() {
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full sm:w-auto" className="w-full sm:w-auto"
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0} disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)}
onClick={() => controlRuntimeServices.mutate("stop")} onClick={() => controlRuntimeServices.mutate("stop")}
> >
Stop Stop

View file

@ -61,6 +61,10 @@ function readText(value: string | null | undefined) {
return value ?? ""; return value ?? "";
} }
function hasActiveRuntimeServices(workspace: ProjectWorkspace | null | undefined) {
return (workspace?.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running");
}
function formatJson(value: Record<string, unknown> | null | undefined) { function formatJson(value: Record<string, unknown> | null | undefined) {
if (!value || Object.keys(value).length === 0) return ""; if (!value || Object.keys(value).length === 0) return "";
return JSON.stringify(value, null, 2); return JSON.stringify(value, null, 2);
@ -624,7 +628,7 @@ export function ProjectWorkspaceDetail() {
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full sm:w-auto" className="w-full sm:w-auto"
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0} disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)}
onClick={() => controlRuntimeServices.mutate("stop")} onClick={() => controlRuntimeServices.mutate("stop")}
> >
Stop Stop