feat(workspaces): choose runtime when creating workspaces

Expose the runtime catalog and carry an explicit runtime ID through workspace creation while preserving omitted-field compatibility for older clients.

The runtime POC made useGitActions require WorkspaceGitBoundary, but Command Center mounted that consumer without the boundary and crashed. Bind the active workspace at that consumer.

Keep provisional Worktree reservations unpublished until final placement so observers never bind the source checkout as the new workspace. Gate legacy cwd Git compatibility by hostVisiblePath instead of runtime kind because Local and Worktree remain host-visible runtimes.
This commit is contained in:
Mohamed Boudra
2026-08-12 11:22:55 +02:00
parent 6ca27a9b12
commit 3a36c76733
63 changed files with 2205 additions and 840 deletions
+59
View File
@@ -2714,6 +2714,65 @@ test("sends first-agent prompt context with workspace.create.request", async ()
});
});
test("lists workspace runtimes and sends an explicit creation runtime", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_runtime_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const listPromise = client.listWorkspaceRuntimes("req-runtime-list");
expect(parseSentFrame(mock.sent.at(-1)!)).toEqual({
type: "workspace.runtime.list.request",
requestId: "req-runtime-list",
});
mock.triggerMessage(
wrapSessionMessage({
type: "workspace.runtime.list.response",
payload: {
requestId: "req-runtime-list",
runtimes: [{ runtimeId: "fixture", builtin: false, requiresGitProject: true }],
},
}),
);
await expect(listPromise).resolves.toMatchObject({
runtimes: [{ runtimeId: "fixture", builtin: false, requiresGitProject: true }],
});
const createPromise = client.createWorkspace(
{
source: { kind: "directory", path: "/tmp/project" },
runtimeId: "fixture",
},
"req-runtime-create",
);
expect(parseSentFrame(mock.sent.at(-1)!)).toEqual({
type: "workspace.create.request",
requestId: "req-runtime-create",
runtimeId: "fixture",
source: { kind: "directory", path: "/tmp/project" },
});
mock.triggerMessage(
wrapSessionMessage({
type: "workspace.create.response",
payload: {
requestId: "req-runtime-create",
workspace: null,
setupTerminalId: null,
error: "fixture sentinel",
},
}),
);
await expect(createPromise).resolves.toMatchObject({ error: "fixture sentinel" });
});
test("sends project.remove.request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
+11
View File
@@ -101,6 +101,7 @@ import type {
PaseoConfigRevision,
WorkspaceCreateRequest,
WorkspaceRecoveryState,
WorkspaceRuntimeListPayload,
} from "@getpaseo/protocol/messages";
import type {
AgentPermissionRequest,
@@ -2195,6 +2196,14 @@ export class DaemonClient {
});
}
async listWorkspaceRuntimes(requestId?: string): Promise<WorkspaceRuntimeListPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: { type: "workspace.runtime.list.request" },
responseType: "workspace.runtime.list.response",
});
}
async openProject(cwd: string, requestId?: string): Promise<OpenProjectPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
@@ -4380,6 +4389,7 @@ export class DaemonClient {
async createWorkspace(
input: {
source: WorkspaceCreateRequest["source"];
runtimeId?: string;
title?: string;
firstAgentContext?: WorkspaceCreateRequest["firstAgentContext"];
},
@@ -4390,6 +4400,7 @@ export class DaemonClient {
message: {
type: "workspace.create.request",
source: input.source,
...(input.runtimeId !== undefined ? { runtimeId: input.runtimeId } : {}),
...(input.title !== undefined ? { title: input.title } : {}),
...(input.firstAgentContext !== undefined
? { firstAgentContext: input.firstAgentContext }
+3
View File
@@ -18,6 +18,7 @@ import type {
SessionOutboundMessage,
WorkspaceDescriptorPayload,
WorkspaceCreateRequest,
WorkspaceRuntimeListPayload,
} from "@getpaseo/protocol/messages";
import { DaemonClient } from "./daemon-client.js";
import type {
@@ -150,6 +151,7 @@ export interface PaseoWorkspaceActions {
requestId?: string,
): Promise<PaseoWorkspaceHandle>;
create(options: PaseoWorkspaceCreateOptions): Promise<PaseoWorkspaceHandle>;
listRuntimes(requestId?: string): Promise<WorkspaceRuntimeListPayload>;
archive(
workspace: string | PaseoWorkspaceHandle,
requestId?: string,
@@ -410,6 +412,7 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient {
}
return createWorkspaceHandle(result.workspace);
},
listRuntimes: (requestId) => daemonClient.listWorkspaceRuntimes(requestId),
archive: (workspace, requestId) =>
daemonClient.archiveWorkspace(resolveWorkspaceId(workspace), requestId),
subscribe: (handler) =>