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
+2 -12
View File
@@ -153,6 +153,7 @@ import {
} from "./workspace-registry.js";
import {
createWorkspaceRuntimeService,
type WorkspaceRuntimeConfig,
type WorkspaceRuntimeService,
} from "./workspace-runtime/index.js";
import { FileBackedChatService } from "./chat/chat-service.js";
@@ -399,18 +400,7 @@ export interface PaseoDaemonConfig {
daemonVersion?: string;
desktopManaged?: boolean;
worktreesRoot?: string;
workspaceRuntimes?: Readonly<
Record<
string,
{
type: "command";
command: readonly [string, ...string[]];
options?: Readonly<Record<string, unknown>>;
helperCommand?: readonly [string, ...string[]];
providerEnvironment?: Readonly<Record<string, string>>;
}
>
>;
workspaceRuntimes?: Readonly<Record<string, WorkspaceRuntimeConfig>>;
corsAllowedOrigins: string[];
allowedHosts?: HostnamesConfig;
hostnames?: HostnamesConfig;
@@ -32,6 +32,12 @@ const FIXTURE_MODEL = "fixture-model";
const fixtureAgentPath = fileURLToPath(
new URL("../test-utils/fixtures/workspace-runtime-acp-agent.mjs", import.meta.url),
);
const fixtureRuntimePath = fileURLToPath(
new URL("../../../../fixture-workspace-runtime/src/index.mjs", import.meta.url),
);
const workspaceHelperPath = fileURLToPath(
new URL("../workspace-helper/executable.mjs", import.meta.url),
);
interface CharacterizedWorkspace {
cwd: string;
@@ -412,6 +418,78 @@ describe("current workspace runtime journeys", () => {
}, 120_000);
});
test("catalog selection creates through a configured runtime while omission remains local", async () => {
const root = mkdtempSync(path.join(tmpdir(), "workspace-runtime-selection-"));
cleanupRoots.push(root);
const repo = path.join(root, "repo");
const stateDirectory = path.join(root, "fixture-state");
mkdirSync(stateDirectory, { recursive: true });
execFileSync("git", ["init", "-b", "main", repo], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { cwd: repo });
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repo });
writeFileSync(path.join(repo, "selection.txt"), "fixture\n");
execFileSync("git", ["add", "."], { cwd: repo });
execFileSync("git", ["commit", "-m", "fixture"], { cwd: repo });
const selectedDaemon = await createTestPaseoDaemon({
mcpEnabled: false,
workspaceRuntimes: {
fixture: {
type: "command",
label: "Fixture",
command: [process.execPath, fixtureRuntimePath],
helperCommand: [process.execPath, workspaceHelperPath],
options: { stateDirectory },
},
},
});
const selectedClient = new DaemonClient({
url: `ws://127.0.0.1:${selectedDaemon.port}/ws`,
reconnect: { enabled: false },
});
try {
await selectedClient.connect();
await expect(selectedClient.listWorkspaceRuntimes()).resolves.toMatchObject({
runtimes: [
{ runtimeId: "local", builtin: true, requiresGitProject: false },
{ runtimeId: "worktree", builtin: true, requiresGitProject: true },
{ runtimeId: "docker", builtin: true, requiresGitProject: true },
{
runtimeId: "fixture",
builtin: false,
label: "Fixture",
requiresGitProject: true,
},
],
});
const explicit = await selectedClient.createWorkspace({
source: { kind: "directory", path: repo },
runtimeId: "fixture",
});
const omitted = await selectedClient.createWorkspace({
source: { kind: "directory", path: repo },
});
if (!explicit.workspace || !omitted.workspace) {
throw new Error(explicit.error ?? omitted.error ?? "Workspace creation failed");
}
const records = JSON.parse(
readFileSync(path.join(selectedDaemon.paseoHome, "projects", "workspaces.json"), "utf8"),
) as Array<{ workspaceId: string; runtime?: { runtimeId: string } }>;
expect(
records.find((record) => record.workspaceId === explicit.workspace?.id)?.runtime,
).toEqual({
runtimeId: "fixture",
});
expect(records.find((record) => record.workspaceId === omitted.workspace?.id)?.runtime).toEqual(
{
runtimeId: "local",
},
);
} finally {
await selectedClient.close().catch(() => undefined);
await selectedDaemon.close();
}
});
test("selected worktree provider journey stays behind the public daemon and client boundary", async () => {
const root = mkdtempSync(path.join(tmpdir(), "workspace-runtime-selected-worktree-"));
cleanupRoots.push(root);
@@ -9,6 +9,7 @@ import {
savePersistedConfig,
} from "./persisted-config.js";
import { PRIVATE_FILE_MODE } from "./private-files.js";
import { createWorkspaceRuntimeService } from "./workspace-runtime/index.js";
const MODE_MASK = 0o777;
const PERMISSIVE_FILE_MODE = 0o644;
@@ -162,6 +163,41 @@ describe("PersistedConfigSchema workspace runtime config", () => {
}),
).toThrow();
});
test("accepts the Docker off switch and rejects command replacement of the reserved id", () => {
expect(
PersistedConfigSchema.parse({
workspaceRuntimes: {
docker: {
type: "docker",
enabled: false,
image: "paseo-workspace:test",
providerEnvironment: { PATH: "/runtime/bin" },
},
},
}).workspaceRuntimes,
).toEqual({
docker: {
type: "docker",
enabled: false,
image: "paseo-workspace:test",
providerEnvironment: { PATH: "/runtime/bin" },
},
});
const configured = PersistedConfigSchema.parse({
workspaceRuntimes: {
docker: { type: "command", command: ["/wrong/runtime"] },
},
});
expect(() =>
createWorkspaceRuntimeService({
paseoHome: "/tmp/paseo-reserved-runtime-test",
externalRuntimes: configured.workspaceRuntimes,
resolveRuntimeId: async () => null,
persistRuntimeId: async () => {},
}),
).toThrow("Workspace runtime id is reserved: docker");
});
});
describe("PersistedConfigSchema provider credentials", () => {
+31 -16
View File
@@ -82,24 +82,39 @@ const WorktreesConfigSchema = z
})
.strict();
const CommandWorkspaceRuntimeConfigSchema = z
.object({
type: z.literal("command"),
label: z.string().min(1).optional(),
command: z
.array(z.string().min(1))
.min(1)
.transform((command) => command as [string, ...string[]]),
options: z.record(z.string(), z.unknown()).optional(),
providerEnvironment: z.record(z.string(), z.string()).optional(),
helperCommand: z
.array(z.string().min(1))
.min(1)
.transform((command) => command as [string, ...string[]])
.optional(),
})
.strict();
const DockerWorkspaceRuntimeConfigSchema = z
.object({
type: z.literal("docker"),
image: z.string().min(1).optional(),
providerEnvironment: z.record(z.string(), z.string()).optional(),
enabled: z.boolean().optional(),
})
.strict();
const WorkspaceRuntimesConfigSchema = z.record(
z.string().min(1),
z
.object({
type: z.literal("command"),
command: z
.array(z.string().min(1))
.min(1)
.transform((command) => command as [string, ...string[]]),
options: z.record(z.string(), z.unknown()).optional(),
providerEnvironment: z.record(z.string(), z.string()).optional(),
helperCommand: z
.array(z.string().min(1))
.min(1)
.transform((command) => command as [string, ...string[]])
.optional(),
})
.strict(),
z.discriminatedUnion("type", [
CommandWorkspaceRuntimeConfigSchema,
DockerWorkspaceRuntimeConfigSchema,
]),
);
const BcryptHashSchema = z.string().regex(/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/, {
+34 -2
View File
@@ -1474,6 +1474,9 @@ export class Session {
if (mutation.expectsInitialAgent) {
this.workspacesAwaitingInitialAgent.add(mutation.workspaceId);
}
if (mutation.provisional) {
return;
}
await this.syncWorkspaceMutationObserver(mutation);
}
if (this.isCleanedUp) {
@@ -1875,6 +1878,7 @@ export class Session {
this.dispatchAgentConfigMessage(msg) ??
this.dispatchCheckoutMessage(msg) ??
this.dispatchWorkspaceRecoveryMessage(msg) ??
this.dispatchWorkspaceRuntimeMessage(msg) ??
this.dispatchWorkspaceAndProjectMessage(msg) ??
this.dispatchWorkspaceFileMessage(msg, source) ??
this.dispatchProviderMessage(msg) ??
@@ -2205,6 +2209,15 @@ export class Session {
}
}
private dispatchWorkspaceRuntimeMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "workspace.runtime.list.request":
return this.handleWorkspaceRuntimeListRequest(msg.requestId);
default:
return undefined;
}
}
private dispatchWorkspaceFileMessage(
msg: SessionInboundMessage,
source?: object,
@@ -5421,6 +5434,14 @@ export class Session {
}
}
private async handleWorkspaceRuntimeListRequest(requestId: string): Promise<void> {
if (!this.workspaceRuntime) throw new Error("Workspace runtime service was not composed");
this.emit({
type: "workspace.runtime.list.response",
payload: { requestId, runtimes: [...this.workspaceRuntime.listRuntimes()] },
});
}
private async handleWorkspaceCreateLocal(
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
): Promise<void> {
@@ -5446,11 +5467,18 @@ export class Session {
const explicitTitle = request.title?.trim() || null;
const promptTitle = resolveFirstAgentPromptTitle(request.firstAgentContext);
const runtimeId = request.runtimeId ?? "local";
if (runtimeId === "worktree") {
throw new Error("The worktree runtime requires a worktree source");
}
if (!this.workspaceRuntime?.listRuntimes().some((runtime) => runtime.runtimeId === runtimeId)) {
throw new Error(`Workspace runtime is not registered: ${runtimeId}`);
}
const workspace = await this.workspaceProvisioning.createWorkspaceForDirectory(
cwd,
explicitTitle ?? promptTitle,
request.source.projectId,
{ expectsInitialAgent: Boolean(request.firstAgentContext), runtimeId: "local" },
{ expectsInitialAgent: Boolean(request.firstAgentContext), runtimeId },
);
const workspaceRuntime = this.workspaceRuntime;
if (!workspaceRuntime) {
@@ -5460,7 +5488,7 @@ export class Session {
try {
await workspaceRuntime.create({
workspaceId: workspace.workspaceId,
runtimeId: "local",
runtimeId,
project: {
id: workspace.projectId,
source: { kind: "host-directory", path: cwd },
@@ -5514,6 +5542,10 @@ export class Session {
return;
}
if (request.runtimeId !== undefined && request.runtimeId !== "worktree") {
throw new Error("A worktree source requires the worktree runtime");
}
const source = request.source;
if (!source.cwd && !source.projectId) {
@@ -291,6 +291,7 @@ export function createWorkspaceProvisioningService(deps: {
});
await workspaceRegistry.upsert(workspace, {
expectsInitialAgent: input.expectsInitialAgent,
provisional: true,
});
return workspace;
}
@@ -1608,6 +1608,8 @@ export class VoiceAssistantWebSocketServer {
checkoutRefresh: true,
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
workspaceMultiplicity: true,
// COMPAT(workspaceRuntimes): added in v0.3.2, remove gate after 2027-02-11.
workspaceRuntimes: true,
// COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97.
projectRemove: true,
// COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97.
@@ -9,6 +9,7 @@ test("selected Git addresses cannot become legacy through empty or omitted ident
const record = {
workspaceId: "workspace-selected",
cwd,
hostVisiblePath: null,
runtime: { runtimeId: "command" },
};
const get = vi.fn(async (workspaceId: string) =>
@@ -52,6 +53,25 @@ test.each(["", " "])(
},
);
test("legacy Git addresses remain available for host-visible runtime placements", async () => {
const cwd = "/shared";
const legacy = { cwd } as WorkspaceGitWorkspace;
const record = {
workspaceId: "workspace-local",
cwd,
hostVisiblePath: cwd,
runtime: { runtimeId: "local" },
};
const bindLegacy = vi.fn(() => legacy);
const directory = createWorkspaceGitDirectory({
workspaceRegistry: { get: vi.fn(), list: async () => [record] },
workspaceGitService: { bindWorkspace: vi.fn(), bindLegacy },
});
await expect(directory.resolve({ kind: "legacy", cwd })).resolves.toBe(legacy);
expect(bindLegacy).toHaveBeenCalledWith(cwd);
});
test("selected Git addresses normalize both fields before registry lookup", async () => {
const record = {
workspaceId: "workspace-a",
@@ -99,11 +99,13 @@ export function createWorkspaceGitDirectory(options: {
}
const cwd = resolve(address.cwd);
const selectedAtCwd = (await workspaceRegistry.list()).some(
const runtimeOnlyAtCwd = (await workspaceRegistry.list()).some(
(record) =>
resolveSelectedWorkspaceRuntimeId(record) !== null && resolve(record.cwd) === cwd,
resolveSelectedWorkspaceRuntimeId(record) !== null &&
resolve(record.cwd) === cwd &&
record.hostVisiblePath === null,
);
if (selectedAtCwd) {
if (runtimeOnlyAtCwd) {
throw new Error("workspaceId is required for a selected workspace Git operation");
}
return workspaceGitService.bindLegacy(cwd);
@@ -419,6 +419,45 @@ describe("workspace registries", () => {
expect(await workspaceRegistry.list()).toEqual([]);
});
test("keeps a runtime reservation out of listings until its final placement is published", async () => {
await workspaceRegistry.initialize();
const reserved = createPersistedWorkspaceRecord({
workspaceId: "reserved-worktree",
projectId: "runtime-project",
cwd: "/tmp/source",
kind: "worktree",
displayName: "feature/runtime",
runtime: { runtimeId: "worktree" },
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
});
const mutations: unknown[] = [];
workspaceRegistry.subscribeToMutations((mutation) => mutations.push(mutation));
await workspaceRegistry.upsert(reserved, { provisional: true });
expect(await workspaceRegistry.get(reserved.workspaceId)).toEqual(reserved);
expect(await workspaceRegistry.list()).toEqual([]);
expect(mutations).toEqual([
expect.objectContaining({ workspaceId: reserved.workspaceId, provisional: true }),
]);
const published = await workspaceRegistry.update(reserved.workspaceId, (workspace) => ({
...workspace,
cwd: "/tmp/worktree",
updatedAt: "2026-03-02T00:00:00.000Z",
}));
expect(await workspaceRegistry.list()).toEqual([published]);
expect(mutations).toHaveLength(2);
expect(mutations[1]).toEqual(
expect.objectContaining({
workspaceId: reserved.workspaceId,
workspace: expect.objectContaining({ cwd: "/tmp/worktree" }),
}),
);
});
test("keeps the committed workspace snapshot when a removal cannot be persisted", async () => {
const registryPath = path.join(tmpDir, "projects", "workspaces.json");
const backupPath = `${registryPath}.backup`;
@@ -120,10 +120,12 @@ export interface WorkspaceMutation {
workspaceId: string;
workspace: PersistedWorkspaceRecord | null;
expectsInitialAgent?: boolean;
provisional?: boolean;
}
export interface WorkspaceMutationContext {
expectsInitialAgent?: boolean;
provisional?: boolean;
}
export interface WorkspaceArchiveContext {
@@ -482,6 +484,7 @@ export class FileBackedWorkspaceRegistry
extends FileBackedRegistry<PersistedWorkspaceRecord>
implements WorkspaceRegistry
{
private readonly provisionalWorkspaceIds = new Set<string>();
private readonly mutationListeners = new Set<
(mutation: WorkspaceMutation) => void | Promise<void>
>();
@@ -503,12 +506,20 @@ export class FileBackedWorkspaceRegistry
return () => this.mutationListeners.delete(listener);
}
override async list(): Promise<PersistedWorkspaceRecord[]> {
const workspaces = await super.list();
return workspaces.filter(
(workspace) => !this.provisionalWorkspaceIds.has(workspace.workspaceId),
);
}
override async update(
workspaceId: string,
updater: (record: PersistedWorkspaceRecord) => PersistedWorkspaceRecord,
): Promise<PersistedWorkspaceRecord | null> {
const workspace = await super.update(workspaceId, updater);
if (workspace) {
this.provisionalWorkspaceIds.delete(workspaceId);
await this.notifyMutation({ kind: "upsert", workspaceId, workspace });
}
return workspace;
@@ -519,11 +530,17 @@ export class FileBackedWorkspaceRegistry
context?: WorkspaceMutationContext,
): Promise<void> {
await super.upsert(record);
if (context?.provisional) {
this.provisionalWorkspaceIds.add(record.workspaceId);
} else {
this.provisionalWorkspaceIds.delete(record.workspaceId);
}
await this.notifyMutation({
kind: "upsert",
workspaceId: record.workspaceId,
workspace: record,
...(context?.expectsInitialAgent ? { expectsInitialAgent: true } : {}),
...(context?.provisional ? { provisional: true } : {}),
});
}
@@ -532,6 +549,7 @@ export class FileBackedWorkspaceRegistry
archivedAt: string,
context?: WorkspaceArchiveContext,
): Promise<void> {
this.provisionalWorkspaceIds.delete(workspaceId);
const workspace = await super.update(workspaceId, (existing) => ({
...existing,
updatedAt: archivedAt,
@@ -545,6 +563,7 @@ export class FileBackedWorkspaceRegistry
}
override async remove(workspaceId: string): Promise<void> {
this.provisionalWorkspaceIds.delete(workspaceId);
const workspace = await this.removeIfPresent(workspaceId);
if (!workspace) return;
await this.notifyMutation({ kind: "remove", workspaceId, workspace: null });
@@ -3,6 +3,7 @@ import { createCommandRuntime } from "./internal/command-runtime.js";
export interface CommandRuntimeAdapterConfig {
command: readonly [string, ...string[]];
label?: string;
options?: Readonly<Record<string, unknown>>;
helperCommand?: readonly [string, ...string[]];
}
@@ -74,6 +74,7 @@ export function createCommandRuntime(
return {
id: runtimeId,
requiresGitProject: true,
reconciliationDomainId: JSON.stringify({ command: config.command, options: config.options }),
workspaceHelperCommand: config.helperCommand ?? ["paseo-workspace-helper"],
async create(input) {
@@ -426,8 +427,12 @@ async function forceKillCommandRuntime(
"--signal",
"SIGKILL",
],
{ env: commandEnvironment(), shell: false, stdio: ["pipe", "ignore", "ignore"] },
{ env: commandEnvironment(), shell: false, stdio: ["pipe", "ignore", "pipe"] },
);
let signalStderr = "";
signalCommand.stderr.on("data", (chunk: Buffer | string) => {
signalStderr += chunk.toString();
});
const signalResult = new Promise<Error | null>((resolve) => {
let finished = false;
const finish = (error: Error | null) => {
@@ -439,7 +444,11 @@ async function forceKillCommandRuntime(
signalCommand.stdin.once("error", (error) => finish(error));
signalCommand.once("close", (code, signal) =>
finish(
code === 0 ? null : new Error(`signal helper failed (${code ?? signal ?? "unknown"})`),
code === 0
? null
: new Error(
`signal helper failed (${code ?? signal ?? "unknown"})${signalStderr.trim() ? `: ${signalStderr.trim()}` : ""}`,
),
),
);
});
@@ -615,18 +624,29 @@ function spawnCommandProcess(
function deliverSignal(signal: NodeJS.Signals): void {
pendingSignal = null;
if (signal === "SIGKILL") {
void forceKillCommandRuntime(
command,
input.workspaceId,
execId,
options,
child,
exited.then(
() => undefined,
() => undefined,
),
).catch(() => undefined);
} else child.kill(signal);
cleanup = (async () => {
try {
await forceKillCommandRuntime(
command,
input.workspaceId,
execId,
options,
child,
wrapperClosedPromise,
);
settled = true;
resolveExit({ code: null, signal });
} catch (error) {
settled = true;
const reason = error instanceof Error ? error.message : String(error);
rejectExit(
new Error(`Workspace runtime ${runtimeId} forced process cleanup failed: ${reason}`),
);
}
})();
return;
}
child.kill(signal);
}
function finish(): void {
@@ -93,6 +93,7 @@ export type WorkspaceDriverProcess = WorkspacePipeProcess | WorkspacePtyProcess;
export interface WorkspaceRuntimeDriver {
readonly id: WorkspaceRuntimeId;
readonly requiresGitProject: boolean;
readonly reconciliationDomainId?: string;
/** Runtime-local command for Paseo's compatible workspace helper. */
readonly workspaceHelperCommand: readonly [string, ...string[]];
@@ -1,4 +1,5 @@
import type { Readable, Writable } from "node:stream";
import { createRequire } from "node:module";
import type { WorkspaceFiles } from "../workspace-helper/index.js";
@@ -117,6 +118,7 @@ export interface BoundWorkspaceRuntime {
}
export interface WorkspaceRuntimeService {
listRuntimes(): readonly WorkspaceRuntimeCatalogEntry[];
reconcile(): Promise<void>;
create(input: CreateWorkspaceInput): Promise<WorkspaceRuntimePlacement>;
run(input: WorkspaceProcessInput): Promise<WorkspaceProcess>;
@@ -133,6 +135,13 @@ export interface WorkspaceRuntimeService {
destroy(workspaceId: string): Promise<void>;
}
export interface WorkspaceRuntimeCatalogEntry {
runtimeId: string;
builtin: boolean;
label?: string;
requiresGitProject: boolean;
}
export interface WorkspaceRuntimePlacement {
workspaceId: string;
runtimeId: string;
@@ -162,6 +171,7 @@ export interface WorkspaceRuntimeRecordStore {
export interface ExternalWorkspaceRuntime {
type: "command";
label?: string;
command: readonly [string, ...string[]];
options?: Readonly<Record<string, unknown>>;
helperCommand?: readonly [string, ...string[]];
@@ -169,34 +179,88 @@ export interface ExternalWorkspaceRuntime {
providerEnvironment?: Readonly<Record<string, string>>;
}
export interface DockerWorkspaceRuntime {
type: "docker";
image?: string;
providerEnvironment?: Readonly<Record<string, string>>;
enabled?: boolean;
}
export type WorkspaceRuntimeConfig = ExternalWorkspaceRuntime | DockerWorkspaceRuntime;
export interface WorkspaceRuntimeOptions extends WorkspaceRuntimeRecordStore {
paseoHome: string;
worktreesRoot?: string;
externalRuntimes?: Readonly<Record<string, ExternalWorkspaceRuntime>>;
externalRuntimes?: Readonly<Record<string, WorkspaceRuntimeConfig>>;
}
export const DEFAULT_DOCKER_WORKSPACE_IMAGE = "ghcr.io/getpaseo/workspace-runtime:latest";
const require = createRequire(import.meta.url);
function resolveDockerRuntimeCommand(): readonly [string, string] {
try {
return [process.execPath, require.resolve("@getpaseo/docker-workspace-runtime")];
} catch (error) {
throw new Error(
"Built-in Docker workspace runtime is unavailable; rebuild @getpaseo/docker-workspace-runtime before starting the daemon",
{ cause: error },
);
}
}
export function createWorkspaceRuntimeService(
options: WorkspaceRuntimeOptions,
): WorkspaceRuntimeService {
const hostGitObservations = createHostGitObservationOwner();
const externalDrivers = Object.entries(options.externalRuntimes ?? {}).map(
([runtimeId, config]) => {
if (runtimeId === "local" || runtimeId === "worktree") {
throw new Error(`Workspace runtime id is reserved: ${runtimeId}`);
}
return createCommandRuntimeAdapter(runtimeId, config);
},
);
return createService(
[
createLocalRuntime(options.paseoHome, hostGitObservations),
createWorktreeRuntime({
paseoHome: options.paseoHome,
worktreesRoot: options.worktreesRoot,
hostGitObservations,
}),
...externalDrivers,
],
options,
const configuredRuntimes = options.externalRuntimes ?? {};
const dockerConfig = configuredRuntimes.docker;
if (dockerConfig?.type === "command") {
throw new Error("Workspace runtime id is reserved: docker");
}
const dockerEnabled = dockerConfig?.enabled !== false;
const externalEntries = Object.entries(configuredRuntimes).filter(
([runtimeId]) => runtimeId !== "docker",
);
const externalDrivers = externalEntries.map(([runtimeId, config]) => {
if (runtimeId === "local" || runtimeId === "worktree") {
throw new Error(`Workspace runtime id is reserved: ${runtimeId}`);
}
if (config.type !== "command") {
throw new Error(`Docker runtime configuration is reserved for docker: ${runtimeId}`);
}
return createCommandRuntimeAdapter(runtimeId, config);
});
const dockerDriver = dockerEnabled
? createCommandRuntimeAdapter("docker", {
command: resolveDockerRuntimeCommand(),
options: { image: dockerConfig?.image ?? DEFAULT_DOCKER_WORKSPACE_IMAGE },
})
: null;
const drivers = [
createLocalRuntime(options.paseoHome, hostGitObservations),
createWorktreeRuntime({
paseoHome: options.paseoHome,
worktreesRoot: options.worktreesRoot,
hostGitObservations,
}),
...(dockerDriver ? [dockerDriver] : []),
...externalDrivers,
];
const catalogMetadata = new Map<string, { builtin: boolean; label?: string }>([
["local", { builtin: true }],
["worktree", { builtin: true }],
...(dockerEnabled ? ([["docker", { builtin: true }]] as const) : []),
...externalEntries.map(
([runtimeId, config]) =>
[
runtimeId,
{
builtin: false,
...(config.type === "command" && config.label ? { label: config.label } : {}),
},
] as const,
),
]);
return createService(drivers, options, catalogMetadata);
}
@@ -54,6 +54,7 @@ export function createLocalRuntime(
return {
id: "local",
requiresGitProject: false,
workspaceHelperCommand: hostWorkspaceHelperCommand,
async create(input: WorkspaceDriverCreateInput) {
const existing = await inspect(input.workspaceId);
@@ -34,6 +34,7 @@ const forcedStopMilliseconds = 1_000;
export function createService(
drivers: readonly WorkspaceRuntimeDriver[],
records: WorkspaceRuntimeRecordStore,
catalogMetadata: ReadonlyMap<string, { builtin: boolean; label?: string }> = new Map(),
): WorkspaceRuntimeService {
const driversById = new Map(drivers.map((driver) => [driver.id, driver]));
const processesByWorkspaceId = new Map<string, Set<WorkspaceDriverProcess>>();
@@ -102,6 +103,16 @@ export function createService(
}
return {
listRuntimes() {
return drivers.map((driver) => {
const metadata = catalogMetadata.get(driver.id) ?? { builtin: false };
return {
runtimeId: driver.id,
...metadata,
requiresGitProject: driver.requiresGitProject,
};
});
},
async reconcile() {
const runtimeRecords = (await records.listRuntimeRecords?.()) ?? [];
const failures: unknown[] = [];
@@ -64,6 +64,7 @@ export function createWorktreeRuntime(options: {
return {
id: "worktree",
requiresGitProject: true,
workspaceHelperCommand: hostWorkspaceHelperCommand,
async create(input: WorkspaceDriverCreateInput) {
const existing = await inspect(input.workspaceId);
@@ -33,7 +33,7 @@ import {
const repositoryRoot = fileURLToPath(new URL("../../../../..", import.meta.url));
const dockerRuntimeRoot = path.join(repositoryRoot, "packages/docker-workspace-runtime");
const runtimeExecutable = path.join(dockerRuntimeRoot, "src/index.ts");
const runtimeExecutable = path.join(dockerRuntimeRoot, "dist/index.js");
const image = "paseo-workspace-runtime-slice1:test";
const fixtureAgent = fileURLToPath(
new URL("../test-utils/fixtures/workspace-runtime-acp-agent.mjs", import.meta.url),
@@ -74,9 +74,8 @@ test("the command runtime owns Docker workspace materialization, pipes, and life
},
externalRuntimes: {
docker: {
type: "command",
command: [process.execPath, "--import", "tsx", runtimeExecutable] as const,
options: { image },
type: "docker",
image,
},
},
};
@@ -339,7 +338,7 @@ test("the command runtime owns Docker workspace materialization, pipes, and life
const pauseStartedAt = Date.now();
await recovered.pause(workspaceId);
expect(Date.now() - pauseStartedAt).toBeLessThan(5_000);
await expect(stubborn.exited).resolves.toEqual({ code: null, signal: "SIGTERM" });
await expect(stubborn.exited).resolves.toEqual({ code: null, signal: "SIGKILL" });
expect(
execFileSync(
"docker",
@@ -387,9 +386,8 @@ test("Docker buffers immediate pipe signals until the workload identity is autho
},
externalRuntimes: {
docker: {
type: "command",
command: [process.execPath, "--import", "tsx", runtimeExecutable],
options: { image },
type: "docker",
image,
},
},
});
@@ -494,9 +492,8 @@ test("a selected Docker service port script executes in the container and leaves
},
externalRuntimes: {
docker: {
type: "command",
command: [process.execPath, "--import", "tsx", runtimeExecutable],
options: { image },
type: "docker",
image,
},
},
});
@@ -544,13 +541,11 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi
const paseoHomeRoot = path.join(root, "daemon-home");
const paseoHome = path.join(paseoHomeRoot, ".paseo");
await mkdir(paseoHome, { recursive: true });
const runtimeConfig = {
type: "command" as const,
command: [process.execPath, "--import", "tsx", runtimeExecutable] as const,
options: { image },
};
const runtimeConfig = { type: "docker" as const, image };
const configuredRuntimeConfig = {
...runtimeConfig,
type: "command" as const,
command: [process.execPath, runtimeExecutable] as const,
options: { image },
providerEnvironment: { PASEO_DAEMON_ONLY_PROVIDER_ENV: "runtime-configured" },
};
const runtimeConfigs = { docker: runtimeConfig, "docker-configured": configuredRuntimeConfig };
@@ -806,9 +801,8 @@ test("Docker creation validates roots and rolls back when runtime selection cann
},
externalRuntimes: {
docker: {
type: "command",
command: [process.execPath, "--import", "tsx", runtimeExecutable],
options: { image },
type: "docker",
image,
},
},
});
@@ -860,9 +854,8 @@ test("Docker rejects a committed symlink cwd that resolves outside the selected
},
externalRuntimes: {
docker: {
type: "command",
command: [process.execPath, "--import", "tsx", runtimeExecutable],
options: { image },
type: "docker",
image,
},
},
});
@@ -918,9 +911,8 @@ test("the Docker runtime never adopts or destroys an unowned deterministic resou
},
externalRuntimes: {
docker: {
type: "command",
command: [process.execPath, "--import", "tsx", runtimeExecutable],
options: { image },
type: "docker",
image,
},
},
});
@@ -983,9 +975,8 @@ test("Docker reconciliation removes only orphaned resources with deterministic n
listRuntimeRecords: async () => [],
externalRuntimes: {
docker: {
type: "command",
command: [process.execPath, "--import", "tsx", runtimeExecutable],
options: { image },
type: "docker",
image,
},
},
});
@@ -30,6 +30,72 @@ afterEach(async () => {
);
});
test("lists built-in and configured runtimes in registration order", async () => {
const root = await temporaryRoot("catalog");
const service = createWorkspaceRuntimeService({
paseoHome: path.join(root, "home"),
externalRuntimes: {
fixture: {
type: "command",
label: "Fixture",
command: [process.execPath, fixtureRuntimeExecutable],
helperCommand: [process.execPath, helperExecutable],
},
},
resolveRuntimeId: async () => null,
persistRuntimeId: async () => {},
});
expect(service.listRuntimes()).toEqual([
{ runtimeId: "local", builtin: true, requiresGitProject: false },
{ runtimeId: "worktree", builtin: true, requiresGitProject: true },
{ runtimeId: "docker", builtin: true, requiresGitProject: true },
{
runtimeId: "fixture",
builtin: false,
label: "Fixture",
requiresGitProject: true,
},
]);
});
test("omits the built-in Docker runtime when its registration is disabled", async () => {
const root = await temporaryRoot("catalog-docker-disabled");
const service = createWorkspaceRuntimeService({
paseoHome: path.join(root, "home"),
externalRuntimes: {
docker: { type: "docker", enabled: false },
fixture: {
type: "command",
label: "Fixture",
command: [process.execPath, fixtureRuntimeExecutable],
helperCommand: [process.execPath, helperExecutable],
},
},
resolveRuntimeId: async () => null,
persistRuntimeId: async () => {},
});
expect(service.listRuntimes()).toEqual([
{ runtimeId: "local", builtin: true, requiresGitProject: false },
{ runtimeId: "worktree", builtin: true, requiresGitProject: true },
{
runtimeId: "fixture",
builtin: false,
label: "Fixture",
requiresGitProject: true,
},
]);
await expect(
service.create({
workspaceId: "disabled-docker",
runtimeId: "docker",
project: { id: "disabled-docker", source: { kind: "host-directory", path: root } },
placement: { kind: "existing" },
}),
).rejects.toThrow("Workspace runtime is not registered: docker");
});
posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId) => {
test("bound runtime exposes only process, file, and command-resolution primitives", async () => {
const fixture = await createFixture(runtimeId);