Add workspace runtime controls

Expose project and execution workspace runtime defaults, control endpoints, startup recovery, and operator UI for start/stop/restart flows.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-28 16:46:43 -05:00
parent f1ad07616c
commit 1f1fe9c989
25 changed files with 1133 additions and 51 deletions

View file

@ -20,6 +20,7 @@ function createProjectWorkspace(overrides: Partial<ProjectWorkspace>): ProjectWo
remoteWorkspaceRef: overrides.remoteWorkspaceRef ?? null,
sharedWorkspaceKey: overrides.sharedWorkspaceKey ?? null,
metadata: overrides.metadata ?? null,
runtimeConfig: overrides.runtimeConfig ?? null,
isPrimary: overrides.isPrimary ?? false,
runtimeServices: overrides.runtimeServices ?? [],
createdAt: overrides.createdAt ?? new Date("2026-03-20T00:00:00Z"),
@ -151,7 +152,7 @@ describe("buildProjectWorkspaceSummaries", () => {
],
});
expect(summaries).toHaveLength(2);
expect(summaries).toHaveLength(3);
expect(summaries[0]).toMatchObject({
key: "execution:exec-1",
kind: "execution_workspace",
@ -172,6 +173,7 @@ describe("buildProjectWorkspaceSummaries", () => {
"issue-feature-newer",
"issue-feature-older",
]);
expect(summaries[2]?.key).toBe("project:workspace-default");
});
it("does not duplicate non-primary workspace issues when an execution workspace owns them", () => {
@ -194,8 +196,9 @@ describe("buildProjectWorkspaceSummaries", () => {
],
});
expect(summaries).toHaveLength(1);
expect(summaries).toHaveLength(2);
expect(summaries[0]?.key).toBe("execution:exec-2");
expect(summaries[1]?.key).toBe("project:workspace-default");
});
it("excludes issues that only use the default shared workspace", () => {
@ -222,6 +225,7 @@ describe("buildProjectWorkspaceSummaries", () => {
],
});
expect(summaries).toHaveLength(0);
expect(summaries).toHaveLength(1);
expect(summaries[0]?.key).toBe("project:workspace-default");
});
});

View file

@ -13,6 +13,10 @@ export interface ProjectWorkspaceSummary {
projectWorkspaceId: string | null;
executionWorkspaceId: string | null;
executionWorkspaceStatus: ExecutionWorkspace["status"] | null;
serviceCount: number;
runningServiceCount: number;
primaryServiceUrl: string | null;
hasRuntimeConfig: boolean;
issues: Issue[];
}
@ -94,6 +98,13 @@ export function buildProjectWorkspaceSummaries(input: {
projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null,
executionWorkspaceId: executionWorkspace.id,
executionWorkspaceStatus: executionWorkspace.status,
serviceCount: executionWorkspace.runtimeServices?.length ?? 0,
runningServiceCount: executionWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
primaryServiceUrl: executionWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
hasRuntimeConfig: Boolean(
executionWorkspace.config?.workspaceRuntime
?? projectWorkspacesById.get(executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? "")?.runtimeConfig?.workspaceRuntime,
),
issues: nextIssues,
});
continue;
@ -119,10 +130,41 @@ export function buildProjectWorkspaceSummaries(input: {
projectWorkspaceId: projectWorkspace.id,
executionWorkspaceId: null,
executionWorkspaceStatus: null,
serviceCount: projectWorkspace.runtimeServices?.length ?? 0,
runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
issues: nextIssues,
});
}
for (const projectWorkspace of input.project.workspaces) {
const key = `project:${projectWorkspace.id}`;
if (summaries.has(key)) continue;
const shouldSurfaceWorkspace =
projectWorkspace.isPrimary
|| Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime)
|| (projectWorkspace.runtimeServices?.length ?? 0) > 0;
if (!shouldSurfaceWorkspace) continue;
summaries.set(key, {
key,
kind: "project_workspace",
workspaceId: projectWorkspace.id,
workspaceName: projectWorkspace.name,
cwd: projectWorkspace.cwd ?? null,
branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null,
lastUpdatedAt: maxDate(projectWorkspace.updatedAt),
projectWorkspaceId: projectWorkspace.id,
executionWorkspaceId: null,
executionWorkspaceStatus: null,
serviceCount: projectWorkspace.runtimeServices?.length ?? 0,
runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0,
primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null,
hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime),
issues: [],
});
}
return [...summaries.values()].sort((a, b) => {
const diff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime();
return diff !== 0 ? diff : a.workspaceName.localeCompare(b.workspaceName);

View file

@ -62,6 +62,7 @@ export const queryKeys = {
["execution-workspaces", companyId, filters ?? {}] as const,
detail: (id: string) => ["execution-workspaces", "detail", id] as const,
closeReadiness: (id: string) => ["execution-workspaces", "close-readiness", id] as const,
workspaceOperations: (id: string) => ["execution-workspaces", "workspace-operations", id] as const,
},
projects: {
list: (companyId: string) => ["projects", companyId] as const,