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,
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);
});
});

View file

@ -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();

View file

@ -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<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) {
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<ExecutionWorkspaceCloseReadiness | null> => {
@ -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({

View file

@ -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;
}

View file

@ -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<Proje
.from(projectWorkspaces)
.where(inArray(projectWorkspaces.projectId, projectIds))
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces(
const runtimeServicesByWorkspaceId = await listCurrentRuntimeServicesForProjectWorkspaces(
db,
rows[0]!.companyId,
workspaceRows.map((workspace) => 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),

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}`);
}
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<string, unknown> | 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<string, unknown> | 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();

View file

@ -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<string, unknown> | 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

View file

@ -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<string, unknown> | 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