diff --git a/docs/glossary.md b/docs/glossary.md index e3ae9fb2bb..c0caff555b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -5,8 +5,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its persisted `projectKey` is an opaque equivalence key that may group the logical project across hosts. A normalized Git remote is the current key producer, but consumers must not parse or rederive it. Git facts can update mutable kind and grouping metadata but never the ID, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label. - **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label. - **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. -- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent). -- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`). +- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Runtime**, which chooses where commands and files live. +- **Runtime** — The environment where a workspace's files, terminals, Git operations, scripts, and agents run. UI: "Runtime" on New Workspace. **Local** adopts the existing host checkout. **Worktree** creates a Paseo-managed host worktree. **Docker** materializes committed Git content in a container. Configured runtimes use their configured label. Code: `WorkspaceRuntimeService`; the remembered choice is `FormPreferences.runtimeId`. - **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run". - **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`). - **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host). @@ -17,7 +17,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Forge** — Git hosting service behind Paseo's change-request features: GitHub, GitLab, Gitea, Forgejo, or a future registered adapter. Code: `ForgeService`, `forge-registry`, `forge-resolver`. Use `forge` for internal abstraction and registry IDs; use concrete forge names only when a behavior or RPC is forge-specific. - **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads. - **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo. -- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym. +- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value and built-in runtime label. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym. - **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label. - **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). diff --git a/package-lock.json b/package-lock.json index a927a5bd61..3018a501f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36432,6 +36432,7 @@ "license": "AGPL-3.0-or-later", "dependencies": { "@getpaseo/cli": "*", + "@getpaseo/docker-workspace-runtime": "*", "@getpaseo/server": "*", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", @@ -38061,6 +38062,7 @@ "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", "@getpaseo/client": "0.3.1", + "@getpaseo/docker-workspace-runtime": "*", "@getpaseo/highlight": "0.3.1", "@getpaseo/protocol": "0.3.1", "@getpaseo/relay": "0.3.1", diff --git a/package.json b/package.json index 48f90c17d0..c1a2040b1b 100644 --- a/package.json +++ b/package.json @@ -57,8 +57,10 @@ "build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client", "build:workspace-runtime-contract": "npm run build --workspace=@getpaseo/workspace-runtime-contract", "build:workspace-runtime-fixture": "npm run build:workspace-runtime-contract && npm run build --workspace=@getpaseo/fixture-workspace-runtime", - "build:server-deps": "npm run build:workspace-runtime-contract && concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"", - "build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean", + "build:docker-workspace-runtime": "npm run build --workspace=@getpaseo/docker-workspace-runtime", + "build:docker-workspace-runtime:clean": "npm run build:clean --workspace=@getpaseo/docker-workspace-runtime", + "build:server-deps": "npm run build:workspace-runtime-contract && concurrently --kill-others-on-fail --names highlight,relay,client,docker-runtime --prefix-colors yellow,blue,cyan,magenta \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\" \"npm run build:docker-workspace-runtime\"", + "build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean && npm run build:docker-workspace-runtime:clean", "build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli", "build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli", "build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs", diff --git a/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts b/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts index d4444d6f22..4ff6574cd2 100644 --- a/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts +++ b/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts @@ -1,10 +1,7 @@ import { expect, test, type Page } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; -import { - openNewWorkspaceComposer, - selectWorkspaceIsolation, -} from "../support/helpers/new-workspace"; +import { openNewWorkspaceComposer, selectWorkspaceRuntime } from "../support/helpers/new-workspace"; import { seedWorkspace } from "../support/helpers/seed-client"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; @@ -177,7 +174,7 @@ test.describe("New Workspace dictation submit", () => { projectKey: seeded.projectKey, projectDisplayName: seeded.projectDisplayName, }); - await selectWorkspaceIsolation(page, "local"); + await selectWorkspaceRuntime(page, "local"); await dictateAndSend(page, harness.waitForAudio); await harness.waitForCreateRequest(); diff --git a/packages/app/e2e/browser/new-workspace-entry.spec.ts b/packages/app/e2e/browser/new-workspace-entry.spec.ts index e6c788a8f4..f15b668e4d 100644 --- a/packages/app/e2e/browser/new-workspace-entry.spec.ts +++ b/packages/app/e2e/browser/new-workspace-entry.spec.ts @@ -4,6 +4,8 @@ import { connectNewWorkspaceDaemonClient, expectNewWorkspaceControlsEnabled, expectNewWorkspaceProjectSelected, + expectWorkspaceRuntimeChoices, + expectWorkspaceRuntimeSelected, expectNewWorkspaceTriggerLabelsAligned, openGlobalNewWorkspaceComposer, openMissingProjectNewWorkspaceComposer, @@ -29,7 +31,7 @@ import { // (preselects that project) — shown for git projects and for non-git projects on // a multiplicity-capable host. These specs prove the global entry opens the // screen, the project icon preselects the right project across the reused 'new' -// screen, and non-git projects never offer the worktree Isolation control. +// screen, and non-git projects offer only the Local runtime. function projectRow(page: import("@playwright/test").Page, projectKey: string) { return page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`); @@ -137,7 +139,7 @@ test.describe("New workspace entry points", () => { await expectNewWorkspaceControlsEnabled(page); }); - test("Ctrl+P opens the project picker with search focused", async ({ page }) => { + test("the project shortcut opens the project picker with search focused", async ({ page }) => { const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" }); try { @@ -246,9 +248,7 @@ test.describe("New workspace entry points", () => { } }); - test("the Isolation control is hidden for a non-git project and shown for a git project", async ({ - page, - }) => { + test("the Runtime control hides Git runtimes for a non-git project", async ({ page }) => { const gitProject: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-iso-git-" }); const nonGitProject: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-iso-nongit-", @@ -274,11 +274,10 @@ test.describe("New workspace entry points", () => { await nonGitOption.click(); await expectNewWorkspaceProjectSelected(page, nonGitProject.projectDisplayName); - // No git checkout means no worktree isolation choice: the Isolation row is - // absent entirely. - await expect(page.getByTestId("workspace-create-isolation-trigger")).toHaveCount(0); + await expectWorkspaceRuntimeSelected(page, "local"); + await expectWorkspaceRuntimeChoices(page, ["local"]); - // Switching to the git project on the same screen reveals the Isolation row. + // Switching to the git project on the same screen adds Worktree. await trigger.click(); const gitOption = page.getByTestId( `new-workspace-project-picker-option-${projectEquivalenceViewKey(gitProject.projectKey)}`, @@ -287,9 +286,8 @@ test.describe("New workspace entry points", () => { await gitOption.click(); await expectNewWorkspaceProjectSelected(page, gitProject.projectDisplayName); - await expect(page.getByTestId("workspace-create-isolation-trigger")).toBeVisible({ - timeout: 30_000, - }); + await expectWorkspaceRuntimeSelected(page, "local"); + await expectWorkspaceRuntimeChoices(page, ["local", "worktree", "docker"]); } finally { await gitProject.cleanup(); await nonGitProject.cleanup(); diff --git a/packages/app/e2e/browser/new-workspace-isolation-memory.spec.ts b/packages/app/e2e/browser/new-workspace-isolation-memory.spec.ts index 7ec3fd4554..f8194a2760 100644 --- a/packages/app/e2e/browser/new-workspace-isolation-memory.spec.ts +++ b/packages/app/e2e/browser/new-workspace-isolation-memory.spec.ts @@ -1,29 +1,24 @@ import { expect, test } from "../support/fixtures"; -import { gotoAppShell } from "../support/helpers/app"; import { - archiveLocalWorkspaceFromDaemon, archiveWorkspaceFromDaemon, assertNewWorkspaceSidebarAndHeader, connectNewWorkspaceDaemonClient, - expectWorkspaceIsolationSelected, + expectWorkspaceRuntimeSelected, openNewWorkspaceComposer, - openProjectViaDaemon, - openStartingRefPicker, - selectBranchInPicker, } from "../support/helpers/new-workspace"; +import { + gotoNewWorkspaceForRuntime, + seedGitProjectForRuntime, +} from "../support/helpers/new-workspace-runtime"; import { expectNoTruncation } from "../support/helpers/no-truncation"; -import { createTempGitRepo } from "../support/helpers/workspace"; import { getServerId } from "../support/helpers/server-id"; -import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; -// Regression for "the local / worktree selection in the new workspace is not -// remembered." The isolation choice persists in the create-form preferences -// (FormPreferences.isolation), so it must survive the create→reopen remount: -// creating a worktree workspace navigates away from /new and unmounts it, and -// reopening New Workspace has to still show "New worktree". -test.describe("New workspace isolation memory", () => { +// Regression for "the Local / Worktree runtime selection in New Workspace is not +// remembered." The runtime choice persists in the create-form preferences, so it +// must survive the create→reopen remount: creating a Worktree workspace +// navigates away from /new and unmounts it. +test.describe("New workspace runtime memory", () => { let client: Awaited>; - const localWorkspaceIds = new Set(); const createdWorktreeDirectories = new Set(); test.describe.configure({ timeout: 240_000 }); @@ -33,45 +28,26 @@ test.describe("New workspace isolation memory", () => { }); test.afterEach(async () => { - if (client) { - for (const workspaceDirectory of createdWorktreeDirectories) { - await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined); - } - for (const workspaceId of localWorkspaceIds) { - await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined); - } + for (const workspaceDirectory of createdWorktreeDirectories) { + await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined); } createdWorktreeDirectories.clear(); - localWorkspaceIds.clear(); await client?.close().catch(() => undefined); }); - test("remembers the worktree isolation choice after creating a workspace", async ({ page }) => { - const serverId = getServerId(); - const tempRepo = await createTempGitRepo("isolation-memory-", { branches: ["main", "dev"] }); + test("remembers the Worktree runtime after creating a workspace", async ({ page }) => { + const project = await seedGitProjectForRuntime(); try { - const openedProject = await openProjectViaDaemon(client, tempRepo.path); - localWorkspaceIds.add(openedProject.workspaceId); - - await gotoAppShell(page); - await waitForSidebarHydration(page); - - // First visit: the screen opens on Local, switch it to New worktree and create. - await openNewWorkspaceComposer(page, { - projectKey: openedProject.projectKey, - projectDisplayName: openedProject.projectDisplayName, - }); - await expectWorkspaceIsolationSelected(page, "local"); - await page.getByTestId("workspace-create-isolation-trigger").click(); - const isolationPopup = page.getByTestId("combobox-desktop-container").last(); - await expect(isolationPopup).toBeVisible({ timeout: 30_000 }); - await expectNoTruncation(isolationPopup); - await page.getByTestId("workspace-create-isolation-worktree").click(); - await expectWorkspaceIsolationSelected(page, "worktree"); - - await openStartingRefPicker(page); - await selectBranchInPicker(page, "dev"); + // First visit: the screen opens on Local, switch it to Worktree and create. + await gotoNewWorkspaceForRuntime(page, project); + await expectWorkspaceRuntimeSelected(page, "local"); + await page.getByRole("button", { name: "Runtime", exact: true }).click(); + const runtimePopup = page.getByRole("dialog").last(); + await expect(runtimePopup).toBeVisible({ timeout: 30_000 }); + await expectNoTruncation(runtimePopup); + await runtimePopup.getByRole("button", { name: "Worktree", exact: true }).click(); + await expectWorkspaceRuntimeSelected(page, "worktree"); const createButton = page .getByTestId("message-input-root") @@ -80,21 +56,21 @@ test.describe("New workspace isolation memory", () => { await createButton.click(); const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { - serverId, + serverId: getServerId(), client, - previousWorkspaceId: openedProject.workspaceId, - projectDisplayName: openedProject.projectDisplayName, + previousWorkspaceId: "", + projectDisplayName: project.projectDisplayName, }); createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory); - // Second visit (fresh mount of /new): the worktree choice must stick. + // Second visit (fresh mount of /new): the runtime choice must stick. await openNewWorkspaceComposer(page, { - projectKey: openedProject.projectKey, - projectDisplayName: openedProject.projectDisplayName, + projectKey: project.projectKey, + projectDisplayName: project.projectDisplayName, }); - await expectWorkspaceIsolationSelected(page, "worktree"); + await expectWorkspaceRuntimeSelected(page, "worktree"); } finally { - await tempRepo.cleanup(); + await project.cleanup(); } }); }); diff --git a/packages/app/e2e/browser/new-workspace-meta-row-layout.spec.ts b/packages/app/e2e/browser/new-workspace-meta-row-layout.spec.ts index 39a3025cf9..a4901cf56c 100644 --- a/packages/app/e2e/browser/new-workspace-meta-row-layout.spec.ts +++ b/packages/app/e2e/browser/new-workspace-meta-row-layout.spec.ts @@ -1,16 +1,17 @@ import { expect, test } from "../support/fixtures"; -import { gotoAppShell } from "../support/helpers/app"; import { getE2EDaemonPort } from "../support/helpers/daemon-port"; import { - openNewWorkspaceComposer, openStartingRefPicker, selectBranchInPicker, - selectWorkspaceIsolation, + selectWorkspaceRuntime, } from "../support/helpers/new-workspace"; -import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { + gotoNewWorkspaceForRuntime, + seedGitProjectForRuntime, + type SeededRuntimeProject, +} from "../support/helpers/new-workspace-runtime"; import { getServerId } from "../support/helpers/server-id"; import { seedSavedSettingsHosts } from "../support/helpers/settings"; -import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; const LONG_HOST_NAME = "development-macbook-pro.local-connected-through-a-very-long-private-hostname"; @@ -27,17 +28,14 @@ function measureControlRightEdges(controls: HTMLElement[]) { } test.describe("New workspace metadata row layout", () => { - let workspace: SeededWorkspace; + let project: SeededRuntimeProject; test.beforeEach(async () => { - workspace = await seedWorkspace({ - repoPrefix: "new-workspace-layout-", - repo: { branches: [LONG_BRANCH_NAME] }, - }); + project = await seedGitProjectForRuntime({ branches: [LONG_BRANCH_NAME] }); }); test.afterEach(async () => { - await workspace?.cleanup(); + await project?.cleanup(); }); test("long host and branch names stay inside the composer's right rail", async ({ page }) => { @@ -55,13 +53,8 @@ test.describe("New workspace metadata row layout", () => { }, ]); - await gotoAppShell(page); - await waitForSidebarHydration(page); - await openNewWorkspaceComposer(page, { - projectKey: workspace.projectKey, - projectDisplayName: workspace.projectDisplayName, - }); - await selectWorkspaceIsolation(page, "worktree"); + await gotoNewWorkspaceForRuntime(page, project); + await selectWorkspaceRuntime(page, "worktree"); await openStartingRefPicker(page); await selectBranchInPicker(page, LONG_BRANCH_NAME); diff --git a/packages/app/e2e/browser/new-workspace.spec.ts b/packages/app/e2e/browser/new-workspace.spec.ts index 7206c9f52e..4bd3852a32 100644 --- a/packages/app/e2e/browser/new-workspace.spec.ts +++ b/packages/app/e2e/browser/new-workspace.spec.ts @@ -32,7 +32,7 @@ import { selectBranchInPicker, selectGitHubPrInPicker, selectPickerOptionByKeyboard, - selectWorkspaceIsolation, + selectWorkspaceRuntime, submitNewWorkspacePrompt, } from "../support/helpers/new-workspace"; import { @@ -723,7 +723,7 @@ test.describe("New workspace flow", () => { projectKey: openedProject.projectKey, projectDisplayName: openedProject.projectDisplayName, }); - await selectWorkspaceIsolation(page, "worktree"); + await selectWorkspaceRuntime(page, "worktree"); await openStartingRefPicker(page); await selectBranchInPicker(page, "dev"); @@ -771,7 +771,7 @@ test.describe("New workspace flow", () => { projectKey: openedProject.projectKey, projectDisplayName: openedProject.projectDisplayName, }); - await selectWorkspaceIsolation(page, "worktree"); + await selectWorkspaceRuntime(page, "worktree"); return openedProject; } @@ -905,7 +905,7 @@ test.describe("New workspace flow", () => { projectKey: openedProject.projectKey, projectDisplayName: openedProject.projectDisplayName, }); - await selectWorkspaceIsolation(page, "worktree"); + await selectWorkspaceRuntime(page, "worktree"); await openBranchPicker(page); await expectPickerOpen(page); @@ -930,7 +930,7 @@ test.describe("New workspace flow", () => { projectKey: openedProject.projectKey, projectDisplayName: openedProject.projectDisplayName, }); - await selectWorkspaceIsolation(page, "worktree"); + await selectWorkspaceRuntime(page, "worktree"); await openBranchPicker(page); await expectPickerOpen(page); @@ -960,7 +960,7 @@ test.describe("New workspace flow", () => { projectKey: openedProject.projectKey, projectDisplayName: openedProject.projectDisplayName, }); - await selectWorkspaceIsolation(page, "worktree"); + await selectWorkspaceRuntime(page, "worktree"); await openStartingRefPicker(page); await selectGitHubPrInPicker(page, pr.number); @@ -996,7 +996,7 @@ test.describe("New workspace flow", () => { projectKey: openedProject.projectKey, projectDisplayName: openedProject.projectDisplayName, }); - await selectWorkspaceIsolation(page, "worktree"); + await selectWorkspaceRuntime(page, "worktree"); await openStartingRefPicker(page); await selectBranchInPicker(page, "main"); @@ -1054,7 +1054,7 @@ test.describe("New workspace flow", () => { projectKey: openedProject.projectKey, projectDisplayName: openedProject.projectDisplayName, }); - await selectWorkspaceIsolation(page, "worktree"); + await selectWorkspaceRuntime(page, "worktree"); await pasteGithubPrUrl(page, context, pr.url); await expectStartingRefPickerTriggerPr(page, { number: pr.number, @@ -1102,7 +1102,7 @@ test.describe("New workspace flow", () => { projectKey: openedProject.projectKey, projectDisplayName: openedProject.projectDisplayName, }); - await selectWorkspaceIsolation(page, "worktree"); + await selectWorkspaceRuntime(page, "worktree"); await openStartingRefPicker(page); await selectGitHubPrInPicker(page, pr.number); await submitNewWorkspaceWithoutPrompt(page); diff --git a/packages/app/e2e/browser/workspace-model-regressions.spec.ts b/packages/app/e2e/browser/workspace-model-regressions.spec.ts index 04cc8bd7d8..63e63792a3 100644 --- a/packages/app/e2e/browser/workspace-model-regressions.spec.ts +++ b/packages/app/e2e/browser/workspace-model-regressions.spec.ts @@ -11,7 +11,7 @@ import { connectNewWorkspaceDaemonClient, expectNewWorkspaceProjectSelected, openGlobalNewWorkspaceComposer, - selectWorkspaceIsolation, + selectWorkspaceRuntime, submitNewWorkspaceEmpty, submitNewWorkspacePrompt, } from "../support/helpers/new-workspace"; @@ -271,7 +271,7 @@ test.describe("Workspace model regressions", () => { await waitForSidebarHydration(page); await openGlobalNewWorkspaceComposer(page); await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName); - await selectWorkspaceIsolation(page, "local"); + await selectWorkspaceRuntime(page, "local"); await submitNewWorkspacePrompt(page, "Fix login bug"); const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { @@ -356,7 +356,7 @@ test.describe("Workspace model regressions", () => { await openGlobalNewWorkspaceComposer(page); await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName); - await selectWorkspaceIsolation(page, "local"); + await selectWorkspaceRuntime(page, "local"); await submitNewWorkspaceEmpty(page); const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { @@ -472,7 +472,7 @@ test.describe("Workspace model regressions", () => { await openGlobalNewWorkspaceComposer(page); await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName); - await selectWorkspaceIsolation(page, "local"); + await selectWorkspaceRuntime(page, "local"); await submitNewWorkspaceEmpty(page); const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { diff --git a/packages/app/e2e/browser/workspace-multiplicity.spec.ts b/packages/app/e2e/browser/workspace-multiplicity.spec.ts index 74d9743184..5149fa4831 100644 --- a/packages/app/e2e/browser/workspace-multiplicity.spec.ts +++ b/packages/app/e2e/browser/workspace-multiplicity.spec.ts @@ -6,7 +6,7 @@ import { connectNewWorkspaceDaemonClient, openGlobalNewWorkspaceComposer, selectNewWorkspaceProject, - selectWorkspaceIsolation, + selectWorkspaceRuntime, submitNewWorkspaceEmpty, } from "../support/helpers/new-workspace"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; @@ -15,8 +15,8 @@ import { getServerId } from "../support/helpers/server-id"; import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; -// Model B reshape: a workspace is the unit, its isolation (local checkout or -// worktree) is a CHOICE at creation, and creation NEVER dedupes by +// Model B reshape: a workspace is the unit, its runtime (Local or Worktree) is +// a CHOICE at creation, and creation NEVER dedupes by // directory. These specs drive the real creation UI (workspace-create-* test // IDs) to prove a single directory can back any number of workspaces. @@ -39,17 +39,16 @@ async function createWorkspaceViaUi( page: Page, input: { project: { projectKey: string; projectDisplayName: string }; - // null when the project has no git checkout: there is no Isolation control to - // touch, the isolation is implicitly local. - isolation: "local" | "worktree" | null; + // null when the project has no git checkout: Local is the only available runtime. + runtime: "local" | "worktree" | null; previousWorkspaceId: string; client: Awaited>; }, ): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> { await openGlobalNewWorkspaceComposer(page); await selectNewWorkspaceProject(page, input.project); - if (input.isolation !== null) { - await selectWorkspaceIsolation(page, input.isolation); + if (input.runtime !== null) { + await selectWorkspaceRuntime(page, input.runtime); } await submitNewWorkspaceEmpty(page); @@ -97,7 +96,7 @@ test.describe("Workspace multiplicity creation flow", () => { const second = await createWorkspaceViaUi(page, { project, - isolation: "local", + runtime: "local", previousWorkspaceId: seeded.workspaceId, client, }); @@ -128,9 +127,7 @@ test.describe("Workspace multiplicity creation flow", () => { } }); - test("New worktree isolation creates a worktree-backed workspace in a distinct directory", async ({ - page, - }) => { + test("the Worktree runtime creates a workspace in a distinct directory", async ({ page }) => { const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "multiplicity-worktree-", }); @@ -149,7 +146,7 @@ test.describe("Workspace multiplicity creation flow", () => { const worktree = await createWorkspaceViaUi(page, { project, - isolation: "worktree", + runtime: "worktree", previousWorkspaceId: seeded.workspaceId, client, }); @@ -200,8 +197,8 @@ test.describe("Workspace multiplicity creation flow", () => { const second = await createWorkspaceViaUi(page, { project, - // Non-git project: no Isolation control, isolation is implicitly local. - isolation: null, + // Non-git project: Local is the only available runtime. + runtime: null, previousWorkspaceId: seeded.workspaceId, client, }); diff --git a/packages/app/e2e/support/helpers/e2e-worker.ts b/packages/app/e2e/support/helpers/e2e-worker.ts index 13c496d660..b5131eba75 100644 --- a/packages/app/e2e/support/helpers/e2e-worker.ts +++ b/packages/app/e2e/support/helpers/e2e-worker.ts @@ -1,5 +1,6 @@ import { execSync } from "node:child_process"; -import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./paseo-home-fork"; @@ -139,6 +140,40 @@ async function applyMetadataFork(targetHome: string, providerIds: string[]): Pro ); } +async function configureDesktopFixtureRuntime(paseoHome: string): Promise { + if (process.env.E2E_DESKTOP_RUNTIME !== "1") return; + const configPath = path.join(paseoHome, "config.json"); + const existing = existsSync(configPath) ? JSON.parse(await readFile(configPath, "utf8")) : {}; + const stateDirectory = path.join(paseoHome, "fixture-runtime"); + await mkdir(stateDirectory, { recursive: true }); + await writeFile( + configPath, + `${JSON.stringify({ + ...existing, + version: 1, + workspaceRuntimes: { + ...existing.workspaceRuntimes, + fixture: { + type: "command", + label: "Fixture", + command: [ + process.execPath, + path.resolve(__dirname, "../../../../fixture-workspace-runtime/src/index.mjs"), + ], + helperCommand: [ + process.execPath, + path.resolve( + __dirname, + "../../../../server/src/server/workspace-helper/executable.mjs", + ), + ], + options: { stateDirectory }, + }, + }, + })}\n`, + ); +} + export async function startE2EWorker( workerIndex: number, options: { forkProviders?: string[] } = {}, @@ -154,6 +189,7 @@ export async function startE2EWorker( try { await applyMetadataFork(paseoHome, options.forkProviders ?? []); + await configureDesktopFixtureRuntime(paseoHome); const daemon = await startIsolatedHostDaemon(serverId, { paseoHome, preserveHome, diff --git a/packages/app/e2e/support/helpers/new-workspace-runtime.ts b/packages/app/e2e/support/helpers/new-workspace-runtime.ts new file mode 100644 index 0000000000..532b69391a --- /dev/null +++ b/packages/app/e2e/support/helpers/new-workspace-runtime.ts @@ -0,0 +1,185 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, type Page, type WebSocket } from "@playwright/test"; +import { gotoAppShell } from "./app"; +import { getE2EDaemonPort } from "./daemon-port"; +import { waitForConnectedHost } from "./hosts"; +import { + assertNewWorkspaceSidebarAndHeader, + connectNewWorkspaceDaemonClient, + openGlobalNewWorkspaceComposer, + selectNewWorkspaceProject, + submitNewWorkspaceEmpty, +} from "./new-workspace"; +import { connectSeedClient } from "./seed-client"; +import { getServerId } from "./server-id"; +import { createTempDirectory, createTempGitRepo } from "./workspace"; + +export interface SeededRuntimeProject { + projectId: string; + projectKey: string; + projectDisplayName: string; + sourceDirectory: string; + cleanup(): Promise; +} + +export async function seedGitProjectForRuntime( + options?: Parameters[1], +): Promise { + const repo = await createTempGitRepo("runtime-selector-", options); + return seedRuntimeProject(repo); +} + +export async function seedNonGitProjectForRuntime(): Promise { + const directory = await createTempDirectory("runtime-selector-non-git-"); + return seedRuntimeProject(directory); +} + +async function seedRuntimeProject(resource: { + path: string; + cleanup(): Promise; +}): Promise { + const client = await connectSeedClient(); + const added = await client.addProject(resource.path); + if (added.error || !added.project) { + await client.close(); + await resource.cleanup(); + throw new Error(added.error ?? "Runtime project was not added"); + } + const listed = await client.listProjects(); + const project = listed.projects.find( + (candidate) => candidate.projectId === added.project?.projectId, + ); + if (!project?.projectKey) { + await client.close(); + await resource.cleanup(); + throw new Error("Runtime project has no project key"); + } + return { + projectId: added.project.projectId, + projectKey: project.projectKey, + projectDisplayName: added.project.projectDisplayName, + sourceDirectory: resource.path, + cleanup: async () => { + await client.removeProject(added.project!.projectId); + await client.close(); + await resource.cleanup(); + }, + }; +} + +export function expectNewWorkspaceProviderSnapshotUsesProjectCwd( + page: Page, + sourceDirectory: string, +): Promise { + return new Promise((resolve, reject) => { + const frameListeners = new Map void>(); + const cleanup = () => { + clearTimeout(timeout); + page.off("websocket", handleWebSocket); + for (const [socket, listener] of frameListeners) socket.off("framesent", listener); + frameListeners.clear(); + }; + const finish = (error?: Error) => { + cleanup(); + if (error) reject(error); + else resolve(); + }; + const handleWebSocket = (socket: WebSocket) => { + const handleFrame = ({ payload }: { payload: string | Buffer }) => { + if (typeof payload !== "string") return; + let frame: unknown; + try { + frame = JSON.parse(payload); + } catch { + return; + } + if (!frame || typeof frame !== "object" || !("message" in frame)) return; + const message = frame.message; + if (!message || typeof message !== "object") return; + if ( + "type" in message && + message.type === "get_providers_snapshot_request" && + "cwd" in message && + message.cwd === sourceDirectory + ) { + finish(); + } + }; + frameListeners.set(socket, handleFrame); + socket.on("framesent", handleFrame); + }; + const timeout = setTimeout( + () => finish(new Error(`No provider snapshot request used cwd ${sourceDirectory}`)), + 30_000, + ); + page.on("websocket", handleWebSocket); + }); +} + +export async function gotoNewWorkspaceForRuntime( + page: Page, + project: SeededRuntimeProject, +): Promise { + await gotoAppShell(page); + await waitForConnectedHost(page, { + serverId: getServerId(), + endpoint: `localhost:${getE2EDaemonPort()}`, + }); + await openGlobalNewWorkspaceComposer(page); + await selectNewWorkspaceProject(page, project); + await expect(page.getByRole("button", { name: "Runtime", exact: true })).toContainText("Local"); +} + +export async function expectRuntimeChoices(page: Page, labels: readonly string[]): Promise { + await page.getByRole("button", { name: "Runtime", exact: true }).click(); + const dialog = page.getByRole("dialog").last(); + for (const label of labels) { + await expect(dialog.getByRole("button", { name: label, exact: true })).toBeVisible(); + } + await expect(dialog.getByRole("button")).toHaveCount(labels.length); + await page.keyboard.press("Escape"); +} + +export async function expectRuntimeSelected(page: Page, label: string): Promise { + await expect(page.getByRole("button", { name: "Runtime", exact: true })).toContainText(label); +} + +export async function selectRuntime(page: Page, label: string): Promise { + const trigger = page.getByRole("button", { name: "Runtime", exact: true }); + await trigger.click(); + await page.getByRole("dialog").last().getByRole("button", { name: label, exact: true }).click(); + await expect(trigger).toContainText(label); +} + +export async function createWorkspaceInSelectedRuntime(page: Page): Promise { + await submitNewWorkspaceEmpty(page); +} + +export async function expectWorkspaceOpenInRuntime( + page: Page, + project: SeededRuntimeProject, + runtimeId: string, +): Promise { + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + try { + const workspace = await assertNewWorkspaceSidebarAndHeader(page, { + serverId: getServerId(), + client, + previousWorkspaceId: "", + projectDisplayName: project.projectDisplayName, + }); + const paseoHome = process.env.E2E_PASEO_HOME; + if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set"); + const records = JSON.parse( + await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"), + ) as Array<{ workspaceId: string; runtime?: { runtimeId: string } }>; + expect(records.find((record) => record.workspaceId === workspace.workspaceId)?.runtime).toEqual( + { + runtimeId, + }, + ); + } finally { + await client.close(); + } +} diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index 5dccd0ccaf..2c758c8df2 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -219,7 +219,7 @@ export async function expectNewWorkspaceControlsEnabled(page: Page): Promise { - await page.keyboard.press("Control+P"); + await page.keyboard.press(process.platform === "darwin" ? "Meta+P" : "Control+P"); const searchInput = page.getByPlaceholder("Search projects"); await expect(searchInput).toBeVisible({ timeout: 30_000 }); @@ -317,35 +317,62 @@ export async function selectNewWorkspaceProject( await expectNewWorkspaceProjectSelected(page, input.projectDisplayName); } -// The isolation trigger renders the active isolation's label ("Local" / "New -// worktree"), so asserting its text proves what the screen currently remembers. -const ISOLATION_TRIGGER_LABEL: Record<"local" | "worktree", string> = { +type WorkspaceRuntimeChoice = "docker" | "local" | "worktree"; + +const WORKSPACE_RUNTIME_LABEL: Record = { + docker: "Docker", local: "Local", - worktree: "New worktree", + worktree: "Worktree", }; -export async function expectWorkspaceIsolationSelected( +export async function expectWorkspaceRuntimeSelected( page: Page, - isolation: "local" | "worktree", + runtime: WorkspaceRuntimeChoice, ): Promise { - const trigger = page.getByRole("button", { name: "Workspace isolation" }); + const trigger = page.getByRole("button", { name: "Runtime", exact: true }); await expect(trigger).toBeVisible({ timeout: 30_000 }); - await expect(trigger).toContainText(ISOLATION_TRIGGER_LABEL[isolation]); + await expect(trigger.getByText(WORKSPACE_RUNTIME_LABEL[runtime], { exact: true })).toBeVisible(); } -export async function selectWorkspaceIsolation( +export async function selectWorkspaceRuntime( page: Page, - isolation: "local" | "worktree", + runtime: WorkspaceRuntimeChoice, ): Promise { - const trigger = page.getByTestId("workspace-create-isolation-trigger"); + const trigger = page.getByRole("button", { name: "Runtime", exact: true }); await expect(trigger).toBeVisible({ timeout: 30_000 }); await trigger.click(); - // Isolation options are derived from project capability. Wait for the option - // so this helper also covers route-to-project reconciliation. - const option = page.getByTestId(`workspace-create-isolation-${isolation}`); + const runtimePicker = page.getByRole("dialog").last(); + await expect(runtimePicker).toBeVisible({ timeout: 30_000 }); + const option = runtimePicker.getByRole("button", { + name: WORKSPACE_RUNTIME_LABEL[runtime], + exact: true, + }); await expect(option).toBeVisible({ timeout: 30_000 }); await option.click(); + await expectWorkspaceRuntimeSelected(page, runtime); +} + +export async function expectWorkspaceRuntimeChoices( + page: Page, + runtimes: readonly WorkspaceRuntimeChoice[], +): Promise { + const trigger = page.getByRole("button", { name: "Runtime", exact: true }); + await expect(trigger).toBeVisible({ timeout: 30_000 }); + await trigger.click(); + + const runtimePicker = page.getByRole("dialog").last(); + await expect(runtimePicker).toBeVisible({ timeout: 30_000 }); + for (const runtime of runtimes) { + await expect( + runtimePicker.getByRole("button", { + name: WORKSPACE_RUNTIME_LABEL[runtime], + exact: true, + }), + ).toBeVisible(); + } + await expect(runtimePicker.getByRole("button")).toHaveCount(runtimes.length); + await page.keyboard.press("Escape"); } export async function submitNewWorkspaceEmpty(page: Page): Promise { @@ -462,7 +489,7 @@ export async function pasteGithubPrUrl( await context.grantPermissions(["clipboard-read", "clipboard-write"]); await page.evaluate((value) => navigator.clipboard.writeText(value), url); await composer.focus(); - await page.keyboard.press("Control+V"); + await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V"); } export async function assertNewWorkspaceSidebarAndHeader( diff --git a/packages/app/src/command-center/workspace-registration.tsx b/packages/app/src/command-center/workspace-registration.tsx index 4963fd835b..e535b56ca3 100644 --- a/packages/app/src/command-center/workspace-registration.tsx +++ b/packages/app/src/command-center/workspace-registration.tsx @@ -5,6 +5,7 @@ import { getIsElectron } from "@/constants/platform"; import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; import { GIT_ACTION_ICONS } from "@/git/action-icons"; import { useGitActionRunner, useGitActions } from "@/git/use-actions"; +import { useBoundWorkspaceGit, WorkspaceGitBoundary } from "@/git/workspace-git"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { resolveShortcutKeysForAction, @@ -13,6 +14,7 @@ import { import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { getShortcutOs } from "@/utils/shortcut-platform"; import { getCommandCenterIcon } from "./icon"; @@ -107,6 +109,26 @@ export function useWorkspaceCommandCenterActions(): void { } export function CommandCenterWorkspaceActions() { + const selection = useActiveWorkspaceSelection(); + const serverId = selection?.serverId ?? ""; + const workspaceId = selection?.workspaceId ?? ""; + const cwd = useWorkspaceDirectory(serverId, workspaceId); + const client = useHostRuntimeClient(serverId); + const address = useMemo( + () => (workspaceId && cwd ? ({ kind: "selected", workspaceId, cwd } as const) : null), + [cwd, workspaceId], + ); + const workspaceGit = useBoundWorkspaceGit(client, address); + if (!workspaceGit) return null; + + return ( + + + + ); +} + +function CommandCenterWorkspaceActionRegistration() { useWorkspaceCommandCenterActions(); return null; } diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx index 7931b95481..f3b6840d4a 100644 --- a/packages/app/src/components/schedules/schedule-form-sheet.tsx +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -174,7 +174,7 @@ function updateSelectionPreferences(input: { ...(model && thinkingOptionId ? { thinkingByModel: { [model]: thinkingOptionId } } : {}), }, }), - isolation: input.isolation, + runtimeId: input.isolation, }; } diff --git a/packages/app/src/create-agent-preferences/optimistic-preferences.test.ts b/packages/app/src/create-agent-preferences/optimistic-preferences.test.ts index 09e289d98a..84c26acee3 100644 --- a/packages/app/src/create-agent-preferences/optimistic-preferences.test.ts +++ b/packages/app/src/create-agent-preferences/optimistic-preferences.test.ts @@ -5,26 +5,26 @@ describe("optimistic form preferences", () => { it("removes a rejected update while preserving later queued updates", () => { const preferences = new OptimisticFormPreferences({}); const failed = preferences.begin({ provider: "claude" }); - const pending = preferences.begin({ isolation: "worktree" }); + const pending = preferences.begin({ runtimeId: "worktree" }); - expect(preferences.current()).toEqual({ provider: "claude", isolation: "worktree" }); + expect(preferences.current()).toEqual({ provider: "claude", runtimeId: "worktree" }); preferences.reject(failed); - expect(preferences.current()).toEqual({ isolation: "worktree" }); + expect(preferences.current()).toEqual({ runtimeId: "worktree" }); - preferences.commit(pending, { isolation: "worktree" }); - expect(preferences.current()).toEqual({ isolation: "worktree" }); + preferences.commit(pending, { runtimeId: "worktree" }); + expect(preferences.current()).toEqual({ runtimeId: "worktree" }); }); it("does not replace pending optimistic state with a stale external snapshot", () => { const preferences = new OptimisticFormPreferences({ provider: "codex" }); const pending = preferences.begin({ provider: "claude" }); - preferences.reconcile({ provider: "codex", isolation: "local" }); + preferences.reconcile({ provider: "codex", runtimeId: "local" }); expect(preferences.current()).toEqual({ provider: "claude" }); preferences.commit(pending, { provider: "claude" }); - preferences.reconcile({ provider: "claude", isolation: "worktree" }); - expect(preferences.current()).toEqual({ provider: "claude", isolation: "worktree" }); + preferences.reconcile({ provider: "claude", runtimeId: "worktree" }); + expect(preferences.current()).toEqual({ provider: "claude", runtimeId: "worktree" }); }); }); diff --git a/packages/app/src/create-agent-preferences/preferences.test.ts b/packages/app/src/create-agent-preferences/preferences.test.ts index 8ba96a4fce..64ace339e5 100644 --- a/packages/app/src/create-agent-preferences/preferences.test.ts +++ b/packages/app/src/create-agent-preferences/preferences.test.ts @@ -60,12 +60,12 @@ describe("create agent preferences", () => { expect(await preferences.load()).toEqual({}); - const successfulWrite = preferences.update({ isolation: "worktree" }); + const successfulWrite = preferences.update({ runtimeId: "worktree" }); await storage.nextWrite(); storage.finishOldestWrite(); await successfulWrite; - expect(storage.savedPreferences()).toEqual({ isolation: "worktree" }); + expect(storage.savedPreferences()).toEqual({ runtimeId: "worktree" }); }); it("flushes the full create-agent selection into provider preferences", async () => { @@ -137,23 +137,32 @@ describe("create agent preferences", () => { expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({}); }); - it("persists and reloads the workspace isolation choice", async () => { + it("persists and reloads the workspace runtime choice", async () => { const storage = new FakeCreateAgentPreferenceStorage(); const preferences = new CreateAgentPreferencesService(storage); - const save = preferences.update({ isolation: "worktree" }); + const save = preferences.update({ runtimeId: "fixture" }); await storage.nextWrite(); storage.finishOldestWrite(); await save; - expect(storage.savedPreferences()).toEqual({ isolation: "worktree" }); + expect(storage.savedPreferences()).toEqual({ runtimeId: "fixture" }); expect(await new CreateAgentPreferencesService(storage).load()).toEqual({ - isolation: "worktree", + runtimeId: "fixture", }); }); - it("treats stored preferences without an isolation choice as undefined", () => { - expect(parseFormPreferences({ provider: "codex" }).isolation).toBeUndefined(); + it("migrates the old isolation choice to a runtime id", () => { + expect(parseFormPreferences({ provider: "codex", isolation: "worktree" })).toEqual({ + provider: "codex", + runtimeId: "worktree", + }); + }); + + it("keeps an explicit runtime id when legacy isolation is also present", () => { + expect( + parseFormPreferences({ provider: "codex", runtimeId: "fixture", isolation: "worktree" }), + ).toEqual({ provider: "codex", runtimeId: "fixture" }); }); it("rejects an unknown isolation value as invalid stored preferences", () => { diff --git a/packages/app/src/create-agent-preferences/preferences.ts b/packages/app/src/create-agent-preferences/preferences.ts index 2c8cfd2bee..48979458b6 100644 --- a/packages/app/src/create-agent-preferences/preferences.ts +++ b/packages/app/src/create-agent-preferences/preferences.ts @@ -38,12 +38,16 @@ const formPreferencesSchema = z.object({ }), ) .optional(), - isolation: z.enum(["local", "worktree"]).optional(), + runtimeId: z.string().min(1).optional(), // What the New workspace composer submits to: the chat agent (default) or a // terminal profile. See `@/new-workspace-launch` for resolution/fallback. launchTarget: launchTargetSchema.optional(), }); +const storedFormPreferencesSchema = formPreferencesSchema.extend({ + isolation: z.enum(["local", "worktree"]).optional(), +}); + export type ProviderPreferences = z.infer; export type FormPreferences = z.infer; export type LaunchTarget = z.infer; @@ -51,8 +55,12 @@ export type LaunchTarget = z.infer; export const DEFAULT_FORM_PREFERENCES: FormPreferences = {}; export function parseFormPreferences(value: unknown): FormPreferences { - const result = formPreferencesSchema.safeParse(value); - return result.success ? result.data : DEFAULT_FORM_PREFERENCES; + const result = storedFormPreferencesSchema.safeParse(value); + if (!result.success) return DEFAULT_FORM_PREFERENCES; + + const { isolation, ...preferences } = result.data; + if (preferences.runtimeId !== undefined || isolation === undefined) return preferences; + return { ...preferences, runtimeId: isolation }; } function mergeDefinedRecord( diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 2329fea347..a4eb16d285 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1042,6 +1042,7 @@ export const ar: TranslationResources = { newWorkspace: { title: "مساحة عمل جديدة", create: "يخلق", + runtime: { local: "محلي", worktree: "شجرة العمل", docker: "Docker", label: "بيئة التشغيل" }, isolation: { local: "محلي", worktree: "شجرة عمل جديدة", @@ -1063,6 +1064,7 @@ export const ar: TranslationResources = { project: "Choose the project", host: "Choose the host", isolation: "Choose the isolation level", + runtime: "Choose where the workspace runs", startingRef: "اختر من أين تبدأ", launch: "Choose what to launch", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 357ccef585..b7c69b5dc8 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1052,6 +1052,7 @@ export const en = { newWorkspace: { title: "New workspace", create: "Create", + runtime: { local: "Local", worktree: "Worktree", docker: "Docker", label: "Runtime" }, isolation: { local: "Local", worktree: "New worktree", @@ -1073,6 +1074,7 @@ export const en = { project: "Choose the project", host: "Choose the host", isolation: "Choose the isolation level", + runtime: "Choose where the workspace runs", startingRef: "Choose where to start from", launch: "Choose what to launch", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index af64e4e766..96bd7d24ee 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1073,6 +1073,7 @@ export const es: TranslationResources = { newWorkspace: { title: "Nuevo espacio de trabajo", create: "Crear", + runtime: { local: "Local", worktree: "Worktree", docker: "Docker", label: "Entorno" }, isolation: { local: "Local", worktree: "Nuevo worktree", @@ -1094,6 +1095,7 @@ export const es: TranslationResources = { project: "Choose the project", host: "Choose the host", isolation: "Choose the isolation level", + runtime: "Choose where the workspace runs", startingRef: "Elige por dónde empezar", launch: "Choose what to launch", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 3cd4fb551f..f79f0a5747 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1072,6 +1072,7 @@ export const fr: TranslationResources = { newWorkspace: { title: "Nouvel espace de travail", create: "Créer", + runtime: { local: "Local", worktree: "Worktree", docker: "Docker", label: "Environnement" }, isolation: { local: "Local", worktree: "Nouveau worktree", @@ -1093,6 +1094,7 @@ export const fr: TranslationResources = { project: "Choose the project", host: "Choose the host", isolation: "Choose the isolation level", + runtime: "Choose where the workspace runs", startingRef: "Choisissez par où commencer", launch: "Choose what to launch", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index a4a5b139fd..424286499b 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1053,6 +1053,7 @@ export const ja: TranslationResources = { newWorkspace: { title: "新しいワークスペース", create: "作成", + runtime: { local: "ローカル", worktree: "ワークツリー", docker: "Docker", label: "ランタイム" }, isolation: { local: "ローカル", worktree: "新しいワークツリー", @@ -1074,6 +1075,7 @@ export const ja: TranslationResources = { project: "Choose the project", host: "Choose the host", isolation: "Choose the isolation level", + runtime: "Choose where the workspace runs", startingRef: "開始点を選択", launch: "Choose what to launch", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 9d43e6ebc7..0f19311867 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1049,6 +1049,7 @@ export const ko: TranslationResources = { newWorkspace: { title: "새 워크스페이스", create: "생성", + runtime: { local: "로컬", worktree: "워크트리", docker: "Docker", label: "런타임" }, isolation: { local: "로컬", worktree: "새 워크트리", @@ -1070,6 +1071,7 @@ export const ko: TranslationResources = { project: "Choose the project", host: "Choose the host", isolation: "Choose the isolation level", + runtime: "Choose where the workspace runs", startingRef: "시작 위치를 선택하세요", launch: "Choose what to launch", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 3659ae9bc8..6ab175f151 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1064,6 +1064,7 @@ export const ptBR: TranslationResources = { newWorkspace: { title: "Novo workspace", create: "Criar", + runtime: { local: "Local", worktree: "Worktree", docker: "Docker", label: "Ambiente" }, isolation: { local: "Local", worktree: "Novo worktree", @@ -1085,6 +1086,7 @@ export const ptBR: TranslationResources = { project: "Choose the project", host: "Choose the host", isolation: "Choose the isolation level", + runtime: "Choose where the workspace runs", startingRef: "Escolha de onde começar", launch: "Choose what to launch", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index c4053e568c..f3713a8c07 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1064,6 +1064,7 @@ export const ru: TranslationResources = { newWorkspace: { title: "Новое рабочее пространство", create: "Создавать", + runtime: { local: "Локально", worktree: "Worktree", docker: "Docker", label: "Среда" }, isolation: { local: "Локально", worktree: "Новый worktree", @@ -1085,6 +1086,7 @@ export const ru: TranslationResources = { project: "Choose the project", host: "Choose the host", isolation: "Choose the isolation level", + runtime: "Choose where the workspace runs", startingRef: "Выберите, с чего начать", launch: "Choose what to launch", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index e4c1fbddbf..e305416179 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1031,6 +1031,7 @@ export const zhCN: TranslationResources = { newWorkspace: { title: "新建 workspace", create: "创建", + runtime: { local: "本地", worktree: "Worktree", docker: "Docker", label: "运行环境" }, isolation: { local: "本地", worktree: "新建 worktree", @@ -1052,6 +1053,7 @@ export const zhCN: TranslationResources = { project: "Choose the project", host: "Choose the host", isolation: "Choose the isolation level", + runtime: "Choose where the workspace runs", startingRef: "选择起始位置", launch: "Choose what to launch", }, diff --git a/packages/app/src/new-workspace-runtime/model.test.ts b/packages/app/src/new-workspace-runtime/model.test.ts new file mode 100644 index 0000000000..cc1f88d3bc --- /dev/null +++ b/packages/app/src/new-workspace-runtime/model.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "vitest"; +import type { WorkspaceRuntimeCatalogEntry } from "@getpaseo/protocol/messages"; + +import { resolveWorkspaceRuntimeSelection, runtimeLabel } from "./model"; + +const LOCAL: WorkspaceRuntimeCatalogEntry = { + runtimeId: "local", + builtin: true, + requiresGitProject: false, +}; +const WORKTREE: WorkspaceRuntimeCatalogEntry = { + runtimeId: "worktree", + builtin: true, + requiresGitProject: true, +}; +const FIXTURE: WorkspaceRuntimeCatalogEntry = { + runtimeId: "fixture", + builtin: false, + label: "Fixture", + requiresGitProject: true, +}; + +describe("new workspace runtime selection", () => { + test("keeps the exact legacy isolation vocabulary when the runtime feature is absent", () => { + const selection = resolveWorkspaceRuntimeSelection({ + catalog: { status: "legacy" }, + selectedRuntimeId: "worktree", + supportsMultiplicity: true, + worktreeSupport: "supported", + }); + + expect(selection).toMatchObject({ + runtimeId: "worktree", + vocabulary: "isolation", + canCreateWorkspace: true, + }); + const translate = (key: string) => + ({ + "newWorkspace.isolation.local": "Local", + "newWorkspace.isolation.worktree": "New worktree", + })[key] ?? key; + expect(runtimeLabel(LOCAL, selection.vocabulary, translate)).toBe("Local"); + expect(runtimeLabel(WORKTREE, selection.vocabulary, translate)).toBe("New worktree"); + }); + + test.each([ + { catalog: { status: "loading" } as const, expectedError: null }, + { + catalog: { status: "error", error: "Runtime catalog failed" } as const, + expectedError: "Runtime catalog failed", + }, + ])( + "preserves a remembered runtime and gates creation while the catalog is $catalog.status", + ({ catalog, expectedError }) => { + expect( + resolveWorkspaceRuntimeSelection({ + catalog, + selectedRuntimeId: "fixture", + supportsMultiplicity: true, + worktreeSupport: "supported", + }), + ).toMatchObject({ + runtimeId: "fixture", + availableRuntimes: [], + canCreateWorkspace: false, + catalogError: expectedError, + vocabulary: "runtime", + }); + }, + ); + + test("omits disabled Docker from the selector after authoritative catalog resolution", () => { + const selection = resolveWorkspaceRuntimeSelection({ + catalog: { status: "ready", runtimes: [LOCAL, WORKTREE, FIXTURE] }, + selectedRuntimeId: "docker", + supportsMultiplicity: true, + worktreeSupport: "supported", + }); + + expect(selection.runtimeId).toBe("local"); + expect(selection.availableRuntimes.map((runtime) => runtime.runtimeId)).toEqual([ + "local", + "worktree", + "fixture", + ]); + expect(selection.canCreateWorkspace).toBe(true); + expect( + runtimeLabel(WORKTREE, selection.vocabulary, (key) => + key === "newWorkspace.runtime.worktree" ? "Worktree" : key, + ), + ).toBe("Worktree"); + }); +}); diff --git a/packages/app/src/new-workspace-runtime/model.ts b/packages/app/src/new-workspace-runtime/model.ts new file mode 100644 index 0000000000..419ffa825d --- /dev/null +++ b/packages/app/src/new-workspace-runtime/model.ts @@ -0,0 +1,104 @@ +import type { WorkspaceRuntimeCatalogEntry } from "@getpaseo/protocol/messages"; + +export type WorkspaceRuntimeCatalogState = + | { status: "legacy" } + | { status: "loading" } + | { status: "ready"; runtimes: readonly WorkspaceRuntimeCatalogEntry[] } + | { status: "error"; error: string }; + +export type WorkspaceRuntimeVocabulary = "isolation" | "runtime"; + +export interface WorkspaceRuntimeSelection { + runtimeId: string; + availableRuntimes: readonly WorkspaceRuntimeCatalogEntry[]; + canCreateWorkspace: boolean; + catalogError: string | null; + showRuntimeControl: boolean; + showRefPicker: boolean; + vocabulary: WorkspaceRuntimeVocabulary; +} + +const LOCAL_RUNTIME: WorkspaceRuntimeCatalogEntry = { + runtimeId: "local", + builtin: true, + requiresGitProject: false, +}; + +const WORKTREE_RUNTIME: WorkspaceRuntimeCatalogEntry = { + runtimeId: "worktree", + builtin: true, + requiresGitProject: true, +}; + +function legacyRuntimeCatalog(canCreateWorktree: boolean): readonly WorkspaceRuntimeCatalogEntry[] { + if (canCreateWorktree) return [LOCAL_RUNTIME, WORKTREE_RUNTIME]; + return [LOCAL_RUNTIME]; +} + +export function runtimeLabelKey( + runtimeId: string, + vocabulary: WorkspaceRuntimeVocabulary, +): string | null { + if (vocabulary === "isolation") { + if (runtimeId === "local") return "newWorkspace.isolation.local"; + if (runtimeId === "worktree") return "newWorkspace.isolation.worktree"; + return null; + } + if (runtimeId === "local") return "newWorkspace.runtime.local"; + if (runtimeId === "worktree") return "newWorkspace.runtime.worktree"; + if (runtimeId === "docker") return "newWorkspace.runtime.docker"; + return null; +} + +export function runtimeLabel( + runtime: Pick, + vocabulary: WorkspaceRuntimeVocabulary, + translate: (key: string) => string, +): string { + const key = runtimeLabelKey(runtime.runtimeId, vocabulary); + return key ? translate(key) : (runtime.label ?? runtime.runtimeId); +} + +export function resolveWorkspaceRuntimeSelection(input: { + catalog: WorkspaceRuntimeCatalogState; + selectedRuntimeId: string; + supportsMultiplicity: boolean; + worktreeSupport: "supported" | "unsupported" | "unknown"; +}): WorkspaceRuntimeSelection { + if (input.catalog.status === "loading" || input.catalog.status === "error") { + return { + runtimeId: input.selectedRuntimeId, + availableRuntimes: [], + canCreateWorkspace: false, + catalogError: input.catalog.status === "error" ? input.catalog.error : null, + showRuntimeControl: true, + showRefPicker: input.selectedRuntimeId === "worktree", + vocabulary: "runtime", + }; + } + + const canCreateWorktree = input.supportsMultiplicity && input.worktreeSupport !== "unsupported"; + const catalog = + input.catalog.status === "legacy" + ? legacyRuntimeCatalog(canCreateWorktree) + : input.catalog.runtimes; + const availableRuntimes = catalog.filter( + (runtime) => !runtime.requiresGitProject || input.worktreeSupport !== "unsupported", + ); + const selectedRuntime = availableRuntimes.find( + (runtime) => runtime.runtimeId === input.selectedRuntimeId, + ); + const runtimeId = + selectedRuntime?.runtimeId ?? availableRuntimes[0]?.runtimeId ?? input.selectedRuntimeId; + const vocabulary = input.catalog.status === "legacy" ? "isolation" : "runtime"; + + return { + runtimeId, + availableRuntimes, + canCreateWorkspace: availableRuntimes.some((runtime) => runtime.runtimeId === runtimeId), + catalogError: null, + showRuntimeControl: vocabulary === "runtime" || canCreateWorktree, + showRefPicker: !input.supportsMultiplicity || runtimeId === "worktree", + vocabulary, + }; +} diff --git a/packages/app/src/schedules/schedule-form-model.test.ts b/packages/app/src/schedules/schedule-form-model.test.ts index 9ec61dd7c8..d16f15c77b 100644 --- a/packages/app/src/schedules/schedule-form-model.test.ts +++ b/packages/app/src/schedules/schedule-form-model.test.ts @@ -703,7 +703,7 @@ describe("schedule form model", () => { thinkingByModel: { "model-b": "high" }, }, }, - isolation: "worktree", + runtimeId: "worktree", }; const create = open({ @@ -750,7 +750,7 @@ describe("schedule form model", () => { }); edit.applyProviderSnapshot("host-a", providerSnapshot(ALL_MODELS)); - applyPreferences(edit, { ...savedPreferences, isolation: "local" }); + applyPreferences(edit, { ...savedPreferences, runtimeId: "local" }); expect(edit.getState()).toMatchObject({ selectedProvider: "mock", diff --git a/packages/app/src/schedules/schedule-form-model.ts b/packages/app/src/schedules/schedule-form-model.ts index 4a889f9ef1..2d62d8e83b 100644 --- a/packages/app/src/schedules/schedule-form-model.ts +++ b/packages/app/src/schedules/schedule-form-model.ts @@ -451,7 +451,7 @@ function resolveInitialIsolation(input: { if (input.config) { return input.config.isolation ?? "local"; } - return input.preferences?.isolation ?? "local"; + return input.preferences?.runtimeId === "worktree" ? "worktree" : "local"; } function resolveSelectedProjectOptionId(target: ScheduleProjectTarget | null): string { @@ -927,9 +927,12 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo if ( snapshot.mode === "create" && !userModified.isolation && - preferences?.isolation !== undefined + preferences?.runtimeId !== undefined ) { - resolved = { ...resolved, isolation: preferences.isolation }; + resolved = { + ...resolved, + isolation: preferences.runtimeId === "worktree" ? "worktree" : "local", + }; } if (providerEntries.length === 0 || resolved.targetKind !== "new-agent") { return resolved; diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index f31de70f7c..33cf8ede34 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import type { ReactElement, RefObject } from "react"; import { useTranslation } from "react-i18next"; -import type { TFunction } from "i18next"; import { Pressable, StyleSheet as RNStyleSheet, Text, View } from "react-native"; import type { PressableStateCallbackType } from "react-native"; import ReanimatedAnimated from "react-native-reanimated"; @@ -9,7 +8,14 @@ import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles" import { useSafeAreaInsets } from "react-native-safe-area-context"; import { createNameId } from "mnemonic-id"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native"; +import { + Box, + ChevronDown, + Folder, + FolderPlus, + GitBranch, + GitPullRequest, +} from "lucide-react-native"; import { Composer } from "@/composer"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { @@ -36,7 +42,7 @@ import { ensureLegacyCheckoutStatus } from "@/git/checkout-status-cache"; import { PreWorkspaceComposerGitOwner } from "@/git/workspace-git"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { resolveTerminalProfiles } from "@getpaseo/protocol/terminal-profiles"; -import type { TerminalProfile } from "@getpaseo/protocol/messages"; +import type { TerminalProfile, WorkspaceRuntimeCatalogEntry } from "@getpaseo/protocol/messages"; import { LaunchControl } from "@/new-workspace-launch/launch-control"; import { resolveLaunchTarget, type LaunchTarget } from "@/new-workspace-launch/target"; import { useTerminalComposerState } from "@/new-workspace-launch/composer-state"; @@ -119,6 +125,12 @@ import { resolveNewWorkspaceInitialServerId, } from "./new-workspace-initial-context"; import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker"; +import { + resolveWorkspaceRuntimeSelection, + runtimeLabel, + type WorkspaceRuntimeCatalogState, + type WorkspaceRuntimeVocabulary, +} from "@/new-workspace-runtime/model"; const ThemedFolderPlus = withUnistyles(FolderPlus); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -157,6 +169,24 @@ function isNewWorkspacePending(input: { return input.pendingAction !== null || input.isDraftHandoffActive; } +function isWorkspaceCreationBlocked(input: { + isPending: boolean; + canCreateWorkspace: boolean; +}): boolean { + return input.isPending || !input.canCreateWorkspace; +} + +function WorkspaceCreationError({ error }: { error: string | null }): ReactElement | null { + return error ? {error} : null; +} + +function resolveWorkspaceCreationError( + actionError: string | null, + catalogError: string | null, +): string | null { + return actionError ?? catalogError; +} + function buildFirstAgentContext(input: { prompt: string; attachments: AgentAttachment[]; @@ -410,7 +440,7 @@ function PickerOptionItem({ ); } -function IsolationOptionItem({ +function RuntimeOptionItem({ optionId, label, selected, @@ -432,18 +462,14 @@ function IsolationOptionItem({ const leadingSlot = useMemo( () => ( - {optionId === "worktree" ? ( - - ) : ( - - )} + ), - [optionId, iconSize, iconColor], + [iconSize, iconColor], ); return ( void; disabled: boolean; badgePressableStyle: React.ComponentProps["style"]; - isolation: "local" | "worktree"; + accessibilityLabel: string; label: string; tooltipLabel: string; iconColor: string; @@ -648,19 +674,15 @@ function IsolationPickerTrigger({ - {isolation === "worktree" ? ( - - ) : ( - - )} + {label} @@ -680,53 +702,48 @@ function FormRow({ children }: { children: React.ReactNode }) { return {children}; } -interface WorkspaceIsolationState { - isolation: "local" | "worktree"; - setIsolation: (value: "local" | "worktree") => void; - effectiveIsolation: "local" | "worktree"; - canCreateWorktree: boolean; +interface WorkspaceRuntimeState { + runtimeId: string; + setRuntimeId: (value: string) => void; + availableRuntimes: readonly WorkspaceRuntimeCatalogEntry[]; + showRuntimeControl: boolean; showRefPicker: boolean; + canCreateWorkspace: boolean; + catalogError: string | null; + vocabulary: WorkspaceRuntimeVocabulary; } -// Preserve the user's worktree choice while route metadata is provisional. Once -// the authoritative placement arrives, unsupported projects fall back to local. -function useWorkspaceIsolation(input: { +// Preserve the remembered choice until the runtime catalog and project metadata +// are authoritative. Only a resolved catalog may choose an available fallback. +function useWorkspaceRuntime(input: { supportsMultiplicity: boolean; worktreeSupport: "supported" | "unsupported" | "unknown"; -}): WorkspaceIsolationState { - const { supportsMultiplicity, worktreeSupport } = input; - // The last isolation choice is remembered alongside the other New Workspace - // form preferences (provider, model, mode). A manual in-screen pick overrides - // the remembered default until the screen remounts. + catalog: WorkspaceRuntimeCatalogState; +}): WorkspaceRuntimeState { const { preferences, updatePreferences } = useFormPreferences(); - const [manualIsolation, setManualIsolation] = useState<"local" | "worktree" | null>(null); - const isolation = manualIsolation ?? preferences.isolation ?? "local"; - const canCreateWorktree = supportsMultiplicity && worktreeSupport !== "unsupported"; - const isWorktree = isolation === "worktree" && canCreateWorktree; + const [manualRuntimeId, setManualRuntimeId] = useState(null); + const selectedRuntimeId = manualRuntimeId ?? preferences.runtimeId ?? "local"; + const selection = resolveWorkspaceRuntimeSelection({ + catalog: input.catalog, + selectedRuntimeId, + supportsMultiplicity: input.supportsMultiplicity, + worktreeSupport: input.worktreeSupport, + }); - const setIsolation = useCallback( - (value: "local" | "worktree") => { - setManualIsolation(value); - void updatePreferences({ isolation: value }); + const setRuntimeId = useCallback( + (value: string) => { + setManualRuntimeId(value); + void updatePreferences({ runtimeId: value }); }, [updatePreferences], ); return { - isolation, - setIsolation, - effectiveIsolation: isWorktree ? "worktree" : "local", - canCreateWorktree, - showRefPicker: !supportsMultiplicity || isWorktree, + ...selection, + setRuntimeId, }; } -function isolationLabel(t: TFunction, isolation: "local" | "worktree"): string { - return isolation === "worktree" - ? t("newWorkspace.isolation.worktree") - : t("newWorkspace.isolation.local"); -} - function getContentStyle(input: { isCompact: boolean; insetBottom: number }) { if (input.isCompact) { return [styles.content, styles.contentCompact, { paddingBottom: input.insetBottom }]; @@ -797,7 +814,8 @@ async function createAndMergeWorkspace(input: { async function createMultiplicityWorkspace(input: { client: NonNullable>; - isolation: "local" | "worktree"; + runtimeId: string; + sendRuntimeId: boolean; project: HostProjectListItem; sourceDirectory: string; checkoutRequest: PickerCheckoutRequest | undefined; @@ -813,12 +831,13 @@ async function createMultiplicityWorkspace(input: { }): Promise> { const projectId = getHostProjectId(input.project, input.serverId); if (!projectId) throw new Error("Project is not available on the selected host"); - const isWorktree = input.isolation === "worktree"; + const isWorktree = input.runtimeId === "worktree"; const firstAgentContext = buildFirstAgentContext({ prompt: input.prompt, attachments: input.attachments, }); const payload = await input.client.createWorkspace({ + ...(input.sendRuntimeId ? { runtimeId: input.runtimeId } : {}), source: isWorktree ? { kind: "worktree", @@ -1312,12 +1331,14 @@ interface NewWorkspaceFormStackInput { selectedServerId: string; onSelect: (id: string) => void; }; - isolation: FormPickerControl & { - effectiveIsolation: "local" | "worktree"; + runtime: FormPickerControl & { + runtimeId: string; options: ComboboxOptionType[]; onSelect: (id: string) => void; renderOption: RefPickerRenderOption; - canCreateWorktree: boolean; + showControl: boolean; + usesRuntimeCatalog: boolean; + triggerLabel: string; }; base: FormPickerControl & { selectedSourceDirectory: string | null; @@ -1340,15 +1361,63 @@ interface NewWorkspaceFormStackInput { }; } +function RuntimeControl({ + runtime, + isPending, + desktopControlStyle, + badgePressableStyle, +}: { + runtime: NewWorkspaceFormStackInput["runtime"]; + isPending: boolean; + desktopControlStyle: React.ComponentProps["style"]; + badgePressableStyle: React.ComponentProps["style"]; +}) { + const { theme } = useUnistyles(); + const { t } = useTranslation(); + return ( + + + + + ); +} + function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactElement { const { theme } = useUnistyles(); const { t } = useTranslation(); - const { isCompact, isPending, project, host, isolation, base, launch } = input; + const { isCompact, isPending, project, host, runtime, base, launch } = input; const selectedHostLabel = host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host"; const showHostControl = host.allHosts.length > 1; - const isolationTriggerLabel = isolationLabel(t, isolation.effectiveIsolation); const addProjectAction = useMemo( () => , [project.onAddProject], @@ -1446,31 +1515,13 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme ) : null; - const isolationControl = isolation.canCreateWorktree ? ( - - - - + const runtimeControl = runtime.showControl ? ( + ) : null; const baseControl = base.showRefPicker ? ( @@ -1520,18 +1571,18 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme {projectControl} {hostControl ? {hostControl} : null} - {isolationControl ? {isolationControl} : null} + {runtimeControl ? {runtimeControl} : null} {baseControl ? {baseControl} : null} {launchControl} {/* Keep fixed stack height without separating the visible controls. */} - {isolationControl ? null : } + {runtimeControl ? null : } {baseControl ? null : } ) : ( {projectControl} {hostControl} - {isolationControl} + {runtimeControl} {baseControl} {launchControl} @@ -1539,6 +1590,28 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme ); } +function useWorkspaceRuntimeCatalog(input: { + serverId: string; + supportsWorkspaceRuntimes: boolean; + isConnected: boolean; + client: ReturnType; +}): WorkspaceRuntimeCatalogState { + const { t } = useTranslation(); + const query = useQuery({ + queryKey: ["workspace-runtimes", input.serverId], + queryFn: async () => { + if (!input.client) throw new Error(t("newWorkspace.errors.hostDisconnected")); + return input.client.listWorkspaceRuntimes(); + }, + enabled: input.supportsWorkspaceRuntimes && input.isConnected && Boolean(input.client), + staleTime: Infinity, + }); + if (!input.supportsWorkspaceRuntimes) return { status: "legacy" }; + if (query.isError) return { status: "error", error: toErrorMessage(query.error) }; + if (query.data) return { status: "ready", runtimes: query.data.runtimes }; + return { status: "loading" }; +} + export function NewWorkspaceScreen({ serverId, sourceDirectory: sourceDirectoryProp, @@ -1572,6 +1645,8 @@ export function NewWorkspaceScreen({ }); // COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97 const supportsWorkspaceMultiplicity = useHostFeature(selectedServerId, "workspaceMultiplicity"); + // COMPAT(workspaceRuntimes): added in v0.3.2, remove gate after 2027-02-11. + const supportsWorkspaceRuntimes = useHostFeature(selectedServerId, "workspaceRuntimes"); const supportsForgeSearch = useHostFeature(selectedServerId, "forgeSearch"); const [errorMessage, setErrorMessage] = useState(null); const [createdWorkspace, setCreatedWorkspace] = useState(null); const projectPickerAnchorRef = useRef(null); - const isolationPickerAnchorRef = useRef(null); + const runtimePickerAnchorRef = useRef(null); const hostPickerAnchorRef = useRef(null); const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId }); @@ -1631,6 +1706,12 @@ export function NewWorkspaceScreen({ const workspace = createdWorkspace; const client = useHostRuntimeClient(selectedServerId); const isConnected = useHostRuntimeIsConnected(selectedServerId); + const runtimeCatalog = useWorkspaceRuntimeCatalog({ + serverId: selectedServerId, + supportsWorkspaceRuntimes, + isConnected, + client, + }); const { selectedProject, selectedSourceDirectory, @@ -1727,11 +1808,21 @@ export function NewWorkspaceScreen({ ? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId }) : "unsupported"; const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive }); - const { effectiveIsolation, setIsolation, canCreateWorktree, showRefPicker } = - useWorkspaceIsolation({ - supportsMultiplicity: supportsWorkspaceMultiplicity, - worktreeSupport, - }); + const { + runtimeId, + setRuntimeId, + availableRuntimes, + showRuntimeControl, + showRefPicker, + canCreateWorkspace, + catalogError, + vocabulary: runtimeVocabulary, + } = useWorkspaceRuntime({ + supportsMultiplicity: supportsWorkspaceMultiplicity, + worktreeSupport, + catalog: runtimeCatalog, + }); + const isCreationBlocked = isWorkspaceCreationBlocked({ isPending, canCreateWorkspace }); const branchSuggestionsQuery = useQuery({ queryKey: [ @@ -1879,31 +1970,43 @@ export function NewWorkspaceScreen({ handle: handleProjectPick, }); - const openIsolationPicker = useCallback(() => { - setIsolationPickerOpen(true); + const openRuntimePicker = useCallback(() => { + setRuntimePickerOpen(true); }, []); - const handleIsolationPickerOpenChange = useCallback((nextOpen: boolean) => { - setIsolationPickerOpen(nextOpen); + const handleRuntimePickerOpenChange = useCallback((nextOpen: boolean) => { + setRuntimePickerOpen(nextOpen); }, []); // "New worktree" is omitted entirely (not disabled) when the project isn't a // git checkout, since worktree isolation is impossible there. - const isolationOptions = useMemo(() => { - const localOption = { id: "local", label: isolationLabel(t, "local") }; - if (!canCreateWorktree) return [localOption]; - return [localOption, { id: "worktree", label: isolationLabel(t, "worktree") }]; - }, [canCreateWorktree, t]); - - const handleSelectIsolationOption = useCallback( - (id: string) => { - setIsolation(id === "worktree" ? "worktree" : "local"); - setIsolationPickerOpen(false); - }, - [setIsolation], + const runtimeOptions = useMemo( + () => + availableRuntimes.map((runtime) => ({ + id: runtime.runtimeId, + label: runtimeLabel(runtime, runtimeVocabulary, t), + })), + [availableRuntimes, runtimeVocabulary, t], + ); + const runtimeTriggerLabel = useMemo( + () => + runtimeLabel( + availableRuntimes.find((runtime) => runtime.runtimeId === runtimeId) ?? { runtimeId }, + runtimeVocabulary, + t, + ), + [availableRuntimes, runtimeId, runtimeVocabulary, t], ); - const renderIsolationOption = useCallback( + const handleSelectRuntimeOption = useCallback( + (id: string) => { + setRuntimeId(id); + setRuntimePickerOpen(false); + }, + [setRuntimeId], + ); + + const renderRuntimeOption = useCallback( ({ option, selected, @@ -1916,7 +2019,7 @@ export function NewWorkspaceScreen({ onPress: () => void; }) => { return ( - )} - {errorMessage ? {errorMessage} : null} + diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 355383516b..9395cbd0e3 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -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(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 1b912a1618..2914aab1f5 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -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 { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "workspace.runtime.list.request" }, + responseType: "workspace.runtime.list.response", + }); + } + async openProject(cwd: string, requestId?: string): Promise { 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 } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ddfb28b26d..218bcb7419 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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; create(options: PaseoWorkspaceCreateOptions): Promise; + listRuntimes(requestId?: string): Promise; 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) => diff --git a/packages/desktop/e2e/new-workspace-runtime.spec.ts b/packages/desktop/e2e/new-workspace-runtime.spec.ts new file mode 100644 index 0000000000..e27b614491 --- /dev/null +++ b/packages/desktop/e2e/new-workspace-runtime.spec.ts @@ -0,0 +1,72 @@ +import { test as base } from "../../app/e2e/support/fixtures"; +import { + createWorkspaceInSelectedRuntime, + expectRuntimeChoices, + expectNewWorkspaceProviderSnapshotUsesProjectCwd, + expectRuntimeSelected, + expectWorkspaceOpenInRuntime, + gotoNewWorkspaceForRuntime, + seedGitProjectForRuntime, + seedNonGitProjectForRuntime, + selectRuntime, + type SeededRuntimeProject, +} from "../../app/e2e/support/helpers/new-workspace-runtime"; +import { getServerId } from "../../app/e2e/support/helpers/server-id"; +import { + openGlobalNewWorkspaceComposer, + selectNewWorkspaceProject, +} from "../../app/e2e/support/helpers/new-workspace"; +import { installDesktopRuntime } from "./support/runtime"; + +const test = base.extend<{ + runtimeProject: SeededRuntimeProject; + nonGitRuntimeProject: SeededRuntimeProject; +}>({ + runtimeProject: async ({ browserName: _browserName }, provide) => { + const project = await seedGitProjectForRuntime(); + try { + await provide(project); + } finally { + await project.cleanup(); + } + }, + nonGitRuntimeProject: async ({ browserName: _browserName }, provide) => { + const project = await seedNonGitProjectForRuntime(); + try { + await provide(project); + } finally { + await project.cleanup(); + } + }, +}); + +test("creates a workspace in a selected runtime", async ({ page, runtimeProject }) => { + await test.step("choose the project and runtime", async () => { + const cwdSnapshotRequest = expectNewWorkspaceProviderSnapshotUsesProjectCwd( + page, + runtimeProject.sourceDirectory, + ); + await installDesktopRuntime(page, { serverId: getServerId(), manageBuiltInDaemon: false }); + await gotoNewWorkspaceForRuntime(page, runtimeProject); + await cwdSnapshotRequest; + await expectRuntimeChoices(page, ["Local", "Worktree", "Docker", "Fixture"]); + await selectRuntime(page, "Fixture"); + }); + + await test.step("create and open the workspace", async () => { + await createWorkspaceInSelectedRuntime(page); + await expectWorkspaceOpenInRuntime(page, runtimeProject, "fixture"); + }); + + await test.step("remember the selected runtime", async () => { + await openGlobalNewWorkspaceComposer(page); + await selectNewWorkspaceProject(page, runtimeProject); + await expectRuntimeSelected(page, "Fixture"); + }); +}); + +test("hides Git runtimes for a non-Git project", async ({ page, nonGitRuntimeProject }) => { + await installDesktopRuntime(page, { serverId: getServerId(), manageBuiltInDaemon: false }); + await gotoNewWorkspaceForRuntime(page, nonGitRuntimeProject); + await expectRuntimeChoices(page, ["Local"]); +}); diff --git a/packages/desktop/package.json b/packages/desktop/package.json index b641d8ce30..29d8df68c7 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "@getpaseo/cli": "*", + "@getpaseo/docker-workspace-runtime": "*", "@getpaseo/server": "*", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts index 8eac5ecc12..85d5d2a9cf 100644 --- a/packages/desktop/src/daemon/desktop-packaging.test.ts +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -108,7 +108,11 @@ describe("desktop packaging", () => { }; const deps = pkg.dependencies ?? {}; - for (const required of ["@getpaseo/cli", "@getpaseo/server"]) { + for (const required of [ + "@getpaseo/cli", + "@getpaseo/docker-workspace-runtime", + "@getpaseo/server", + ]) { expect(deps[required], `${required} must be declared in dependencies`).toBe("*"); } }); diff --git a/packages/docker-workspace-runtime/package.json b/packages/docker-workspace-runtime/package.json index 79e1774b0a..6a49386b9c 100644 --- a/packages/docker-workspace-runtime/package.json +++ b/packages/docker-workspace-runtime/package.json @@ -6,8 +6,11 @@ "paseo-docker-workspace-runtime": "./dist/index.js" }, "type": "module", + "main": "./dist/index.js", "scripts": { + "clean": "node ../../scripts/clean-package-dist.mjs", "build": "tsc -p tsconfig.json", + "build:clean": "npm run clean && npm run build", "typecheck": "tsgo -p tsconfig.json --noEmit" }, "dependencies": { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 2b1c24547c..d36f776430 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2172,12 +2172,25 @@ export const ArchiveWorkspaceRequestSchema = z.object({ requestId: z.string(), }); +export const WorkspaceRuntimeCatalogEntrySchema = z.object({ + runtimeId: z.string(), + builtin: z.boolean(), + label: z.string().optional(), + requiresGitProject: z.boolean(), +}); + +export const WorkspaceRuntimeListRequestSchema = z.object({ + type: z.literal("workspace.runtime.list.request"), + requestId: z.string(), +}); + // Create a new workspace record. Unlike open_project, this never deduplicates by // directory: it always produces a fresh workspace. The source discriminates // between an existing local directory and a newly created paseo worktree. export const WorkspaceCreateRequestSchema = z.object({ type: z.literal("workspace.create.request"), requestId: z.string(), + runtimeId: z.string().optional(), // Optional user-set title applied to the created workspace. title: z.string().optional(), // Optional prompt context for workspace-level name/branch generation. @@ -2733,6 +2746,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ProjectGithubCloneRequestSchema, ArchiveWorkspaceRequestSchema, WorkspaceCreateRequestSchema, + WorkspaceRuntimeListRequestSchema, WorkspaceClearAttentionRequestSchema, FileExplorerRequestSchema, FileSubscribeRequestSchema, @@ -2981,6 +2995,8 @@ export const ServerInfoStatusPayloadSchema = z checkoutRefresh: z.boolean().optional(), // COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97 workspaceMultiplicity: z.boolean().optional(), + // COMPAT(workspaceRuntimes): added in v0.3.2, remove gate after 2027-02-11. + workspaceRuntimes: z.boolean().optional(), // COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97. projectRemove: z.boolean().optional(), // COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97. @@ -3963,6 +3979,14 @@ export const WorkspaceCreateResponseSchema = z.object({ }), }); +export const WorkspaceRuntimeListResponseSchema = z.object({ + type: z.literal("workspace.runtime.list.response"), + payload: z.object({ + runtimes: z.array(WorkspaceRuntimeCatalogEntrySchema), + requestId: z.string(), + }), +}); + export const WorkspaceClearAttentionResponseSchema = z.object({ type: z.literal("workspace.clear_attention.response"), payload: z.object({ @@ -5551,6 +5575,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CancelAgentResponseMessageSchema, ClearAgentAttentionResponseMessageSchema, WorkspaceCreateResponseSchema, + WorkspaceRuntimeListResponseSchema, WorkspaceClearAttentionResponseSchema, SendAgentMessageResponseMessageSchema, SetVoiceModeResponseMessageSchema, @@ -5778,6 +5803,10 @@ export type WorkspaceRecoveryRestoreResponse = z.infer< >; export type WorkspaceCreateRequest = z.infer; export type WorkspaceCreateResponse = z.infer; +export type WorkspaceRuntimeCatalogEntry = z.infer; +export type WorkspaceRuntimeListPayload = z.infer< + typeof WorkspaceRuntimeListResponseSchema +>["payload"]; export type ProjectRenameResponsePayload = z.infer; export type ProjectRemoveResponsePayload = z.infer; export type WaitForFinishResponseMessage = z.infer; diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index 03a0bd4321..b2c0b00663 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -5,6 +5,8 @@ import { SessionInboundMessageSchema, SessionOutboundMessageSchema, WorkspaceCreateRequestSchema, + WorkspaceRuntimeListRequestSchema, + WorkspaceRuntimeListResponseSchema, WorkspaceDescriptorPayloadSchema, WorkspaceScriptPayloadSchema, } from "./messages.js"; @@ -1118,4 +1120,45 @@ describe("workspace message schemas", () => { expect(newDirectory.type).toBe("workspace.create.request"); expect(newDirectory.source.kind).toBe("directory"); }); + + test("workspace runtime catalog and optional create selection stay wire-compatible", () => { + expect( + WorkspaceRuntimeListRequestSchema.parse({ + type: "workspace.runtime.list.request", + requestId: "runtime-list", + }), + ).toEqual({ type: "workspace.runtime.list.request", requestId: "runtime-list" }); + expect( + WorkspaceRuntimeListResponseSchema.parse({ + type: "workspace.runtime.list.response", + payload: { + requestId: "runtime-list", + runtimes: [ + { runtimeId: "local", builtin: true, requiresGitProject: false }, + { + runtimeId: "fixture", + builtin: false, + label: "Fixture", + requiresGitProject: true, + }, + ], + }, + }).payload.runtimes, + ).toHaveLength(2); + expect( + WorkspaceCreateRequestSchema.parse({ + type: "workspace.create.request", + requestId: "old-client", + source: { kind: "directory", path: "/repo" }, + }), + ).not.toHaveProperty("runtimeId"); + expect( + WorkspaceCreateRequestSchema.parse({ + type: "workspace.create.request", + requestId: "new-client", + source: { kind: "directory", path: "/repo" }, + runtimeId: "fixture", + }).runtimeId, + ).toBe("fixture"); + }); }); diff --git a/packages/server/package.json b/packages/server/package.json index 4f347d2a5c..89b0537c67 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -73,6 +73,7 @@ "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", "@getpaseo/client": "0.3.1", + "@getpaseo/docker-workspace-runtime": "*", "@getpaseo/highlight": "0.3.1", "@getpaseo/protocol": "0.3.1", "@getpaseo/relay": "0.3.1", diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 4bf0209093..ced6a77a97 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -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>; - helperCommand?: readonly [string, ...string[]]; - providerEnvironment?: Readonly>; - } - > - >; + workspaceRuntimes?: Readonly>; corsAllowedOrigins: string[]; allowedHosts?: HostnamesConfig; hostnames?: HostnamesConfig; diff --git a/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts index 9687df4c10..d6941180c9 100644 --- a/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts @@ -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); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 9689f7a2cc..2eca0eb155 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -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", () => { diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 7328e629ba..e33f6ab3b2 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -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}$/, { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1504d2b89a..1908746e06 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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 | 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 { + 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, ): Promise { @@ -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) { diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 17a5c76b42..e70ab6e3f3 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -291,6 +291,7 @@ export function createWorkspaceProvisioningService(deps: { }); await workspaceRegistry.upsert(workspace, { expectsInitialAgent: input.expectsInitialAgent, + provisional: true, }); return workspace; } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 835b774efd..ce5634bfd4 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -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. diff --git a/packages/server/src/server/workspace-git-directory.test.ts b/packages/server/src/server/workspace-git-directory.test.ts index edda78196f..2ce31e4a9a 100644 --- a/packages/server/src/server/workspace-git-directory.test.ts +++ b/packages/server/src/server/workspace-git-directory.test.ts @@ -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", diff --git a/packages/server/src/server/workspace-git-directory.ts b/packages/server/src/server/workspace-git-directory.ts index e6de490283..99afef1b7c 100644 --- a/packages/server/src/server/workspace-git-directory.ts +++ b/packages/server/src/server/workspace-git-directory.ts @@ -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); diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 217adc5181..33621c9cfa 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -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`; diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index ae84134d08..d8e6f8cc83 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -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 implements WorkspaceRegistry { + private readonly provisionalWorkspaceIds = new Set(); private readonly mutationListeners = new Set< (mutation: WorkspaceMutation) => void | Promise >(); @@ -503,12 +506,20 @@ export class FileBackedWorkspaceRegistry return () => this.mutationListeners.delete(listener); } + override async list(): Promise { + const workspaces = await super.list(); + return workspaces.filter( + (workspace) => !this.provisionalWorkspaceIds.has(workspace.workspaceId), + ); + } + override async update( workspaceId: string, updater: (record: PersistedWorkspaceRecord) => PersistedWorkspaceRecord, ): Promise { 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 { 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 { + 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 { + this.provisionalWorkspaceIds.delete(workspaceId); const workspace = await this.removeIfPresent(workspaceId); if (!workspace) return; await this.notifyMutation({ kind: "remove", workspaceId, workspace: null }); diff --git a/packages/server/src/server/workspace-runtime/command/index.ts b/packages/server/src/server/workspace-runtime/command/index.ts index b952f51ab6..582f2989b2 100644 --- a/packages/server/src/server/workspace-runtime/command/index.ts +++ b/packages/server/src/server/workspace-runtime/command/index.ts @@ -3,6 +3,7 @@ import { createCommandRuntime } from "./internal/command-runtime.js"; export interface CommandRuntimeAdapterConfig { command: readonly [string, ...string[]]; + label?: string; options?: Readonly>; helperCommand?: readonly [string, ...string[]]; } diff --git a/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts b/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts index 5e25d44be8..e1d7343770 100644 --- a/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts +++ b/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts @@ -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((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 { diff --git a/packages/server/src/server/workspace-runtime/drivers/index.ts b/packages/server/src/server/workspace-runtime/drivers/index.ts index 6878a9bf22..6b4e943a49 100644 --- a/packages/server/src/server/workspace-runtime/drivers/index.ts +++ b/packages/server/src/server/workspace-runtime/drivers/index.ts @@ -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[]]; diff --git a/packages/server/src/server/workspace-runtime/index.ts b/packages/server/src/server/workspace-runtime/index.ts index f39f6a5c60..591abe089b 100644 --- a/packages/server/src/server/workspace-runtime/index.ts +++ b/packages/server/src/server/workspace-runtime/index.ts @@ -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; create(input: CreateWorkspaceInput): Promise; run(input: WorkspaceProcessInput): Promise; @@ -133,6 +135,13 @@ export interface WorkspaceRuntimeService { destroy(workspaceId: string): Promise; } +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>; helperCommand?: readonly [string, ...string[]]; @@ -169,34 +179,88 @@ export interface ExternalWorkspaceRuntime { providerEnvironment?: Readonly>; } +export interface DockerWorkspaceRuntime { + type: "docker"; + image?: string; + providerEnvironment?: Readonly>; + enabled?: boolean; +} + +export type WorkspaceRuntimeConfig = ExternalWorkspaceRuntime | DockerWorkspaceRuntime; + export interface WorkspaceRuntimeOptions extends WorkspaceRuntimeRecordStore { paseoHome: string; worktreesRoot?: string; - externalRuntimes?: Readonly>; + externalRuntimes?: Readonly>; +} + +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([ + ["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); } diff --git a/packages/server/src/server/workspace-runtime/internal/local-runtime.ts b/packages/server/src/server/workspace-runtime/internal/local-runtime.ts index ccbb688041..368a0ec9a0 100644 --- a/packages/server/src/server/workspace-runtime/internal/local-runtime.ts +++ b/packages/server/src/server/workspace-runtime/internal/local-runtime.ts @@ -54,6 +54,7 @@ export function createLocalRuntime( return { id: "local", + requiresGitProject: false, workspaceHelperCommand: hostWorkspaceHelperCommand, async create(input: WorkspaceDriverCreateInput) { const existing = await inspect(input.workspaceId); diff --git a/packages/server/src/server/workspace-runtime/internal/service.ts b/packages/server/src/server/workspace-runtime/internal/service.ts index aed6bcbb10..73aef4e4eb 100644 --- a/packages/server/src/server/workspace-runtime/internal/service.ts +++ b/packages/server/src/server/workspace-runtime/internal/service.ts @@ -34,6 +34,7 @@ const forcedStopMilliseconds = 1_000; export function createService( drivers: readonly WorkspaceRuntimeDriver[], records: WorkspaceRuntimeRecordStore, + catalogMetadata: ReadonlyMap = new Map(), ): WorkspaceRuntimeService { const driversById = new Map(drivers.map((driver) => [driver.id, driver])); const processesByWorkspaceId = new Map>(); @@ -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[] = []; diff --git a/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts index f8d1c1acf2..b05a1eec66 100644 --- a/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts +++ b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts @@ -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); diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts index fc2c2804ce..acdaf0ee32 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts @@ -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, }, }, }); diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts index 95e1bdda78..ccb3bc2c4b 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts @@ -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); diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index ad22d8c57b..8a475dd2db 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -1,222 +1,222 @@ { - "$ref": "#/definitions/PaseoConfigV1", - "definitions": { - "PaseoConfigV1": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "version": { + "type": "number", + "const": 1 + }, + "daemon": { "type": "object", "properties": { - "$schema": { + "listen": { "type": "string" }, - "version": { - "type": "number", - "const": 1 + "hostnames": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, - "daemon": { + "allowedHosts": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "trustedProxies": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "mcp": { "type": "object", "properties": { + "enabled": { + "type": "boolean" + }, + "injectIntoAgents": { + "type": "boolean" + } + }, + "additionalProperties": {} + }, + "browserTools": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "additionalProperties": {} + }, + "git": { + "type": "object", + "properties": { + "maxProcessesPerSecond": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxProcessConcurrency": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "autoArchiveAfterMerge": { + "type": "boolean" + }, + "enableTerminalAgentHooks": { + "type": "boolean" + }, + "appendSystemPrompt": { + "type": "string" + }, + "terminalProfiles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "icon": { + "type": "string" + } + }, + "required": ["id", "name", "command"], + "additionalProperties": {} + } + }, + "cors": { + "type": "object", + "properties": { + "allowedOrigins": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "relay": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "endpoint": { + "type": "string" + }, + "publicEndpoint": { + "type": "string" + }, + "useTls": { + "type": "boolean" + }, + "publicUseTls": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "serviceProxy": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, "listen": { "type": "string" }, - "hostnames": { - "anyOf": [ - { - "type": "boolean", - "const": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "allowedHosts": { - "anyOf": [ - { - "type": "boolean", - "const": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "trustedProxies": { - "description": "Express trusted proxy setting for X-Forwarded-* headers. Defaults to [\"loopback\"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.", - "anyOf": [ - { - "type": "boolean", - "const": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "mcp": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "injectIntoAgents": { - "type": "boolean" - } - }, - "additionalProperties": true - }, - "git": { - "type": "object", - "properties": { - "maxProcessesPerSecond": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "maxProcessConcurrency": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "additionalProperties": false - }, - "autoArchiveAfterMerge": { - "type": "boolean" - }, - "enableTerminalAgentHooks": { - "type": "boolean" - }, - "appendSystemPrompt": { - "type": "string" - }, - "cors": { - "type": "object", - "properties": { - "allowedOrigins": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - }, - "relay": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "endpoint": { - "type": "string" - }, - "publicEndpoint": { - "type": "string" - }, - "useTls": { - "type": "boolean" - }, - "publicUseTls": { - "type": "boolean" - } - }, - "additionalProperties": false - }, - "serviceProxy": { - "type": "object", - "properties": { - "enabled": { - "description": "Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.", - "type": "boolean" - }, - "listen": { - "description": "Optional service-only listener address. Presence enables the standalone service proxy listener.", - "type": "string" - }, - "publicBaseUrl": { - "description": "Optional public base URL for service host aliases.", - "type": "string", - "format": "uri" - } - }, - "additionalProperties": false - }, - "auth": { - "type": "object", - "properties": { - "password": { - "type": "string", - "pattern": "^\\$2[aby]\\$\\d{2}\\$[./A-Za-z0-9]{53}$" - } - }, - "additionalProperties": false + "publicBaseUrl": { + "type": "string", + "format": "uri" } }, "additionalProperties": false }, - "app": { + "auth": { "type": "object", "properties": { - "baseUrl": { - "type": "string" + "password": { + "type": "string", + "pattern": "^\\$2[aby]\\$\\d{2}\\$[./A-Za-z0-9]{53}$" } }, "additionalProperties": false - }, - "worktrees": { + } + }, + "additionalProperties": false + }, + "app": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string" + } + }, + "additionalProperties": false + }, + "providers": { + "type": "object", + "properties": { + "openai": { "type": "object", "properties": { - "root": { + "apiKey": { "type": "string", "minLength": 1 - } - }, - "additionalProperties": false - }, - "workspaceRuntimes": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "command" - }, - "command": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - }, - "minItems": 1 - }, - "options": { - "type": "object", - "additionalProperties": {} - }, - "helperCommand": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - }, - "minItems": 1 - } }, - "required": ["type", "command"], - "additionalProperties": false - } - }, - "providers": { - "type": "object", - "properties": { - "openai": { + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "stt": { "type": "object", "properties": { "apiKey": { @@ -226,42 +226,18 @@ "baseUrl": { "type": "string", "minLength": 1 - }, - "stt": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - }, - "tts": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false } }, "additionalProperties": false }, - "local": { + "tts": { "type": "object", "properties": { - "modelsDir": { + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { "type": "string", "minLength": 1 } @@ -271,228 +247,331 @@ }, "additionalProperties": false }, - "agents": { + "local": { "type": "object", "properties": { - "providers": { - "type": "object", - "additionalProperties": { + "modelsDir": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "worktrees": { + "type": "object", + "properties": { + "root": { + "type": "string", + "minLength": 1 + }, + "servicePorts": { + "type": "object", + "properties": { + "range": { + "type": "string", + "pattern": "^(\\d{1,5})-(\\d{1,5})$" + }, + "portScript": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "workspaceRuntimes": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "command" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "command": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "options": { "type": "object", - "properties": { - "extends": { - "type": "string" - }, - "label": { - "type": "string" - }, - "description": { - "type": "string" - }, - "command": { - "type": "array", - "items": { + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "providerEnvironment": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "helperCommand": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": ["type", "command"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "docker" + }, + "image": { + "type": "string", + "minLength": 1 + }, + "providerEnvironment": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + } + }, + "required": ["type"], + "additionalProperties": false + } + ] + } + }, + "agents": { + "type": "object", + "properties": { + "providers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "extends": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "command": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "env": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "params": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "models": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string", "minLength": 1 }, - "minItems": 1 - }, - "env": { - "type": "object", - "additionalProperties": { + "label": { + "type": "string", + "minLength": 1 + }, + "description": { "type": "string" - } - }, - "models": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1 - }, - "label": { - "type": "string", - "minLength": 1 - }, - "description": { - "type": "string" - }, - "isDefault": { - "type": "boolean" - }, - "thinkingOptions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "label": { - "type": "string" - }, - "description": { - "type": "string" - }, - "isDefault": { - "type": "boolean" - } - }, - "required": ["id", "label"], - "additionalProperties": false + }, + "isDefault": { + "type": "boolean" + }, + "thinkingOptions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isDefault": { + "type": "boolean" } - } - }, - "required": ["id", "label"], - "additionalProperties": false + }, + "required": ["id", "label"] + } } }, - "additionalModels": { - "type": "array", - "items": { - "$ref": "#/definitions/PaseoConfigV1/properties/agents/properties/providers/additionalProperties/properties/models/items" - } - }, - "disallowedTools": { - "type": "array", - "items": { + "required": ["id", "label"] + } + }, + "additionalModels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "thinkingOptions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + } + }, + "required": ["id", "label"] + } } }, - "enabled": { - "type": "boolean" + "required": ["id", "label"] + } + }, + "disallowedTools": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "order": { + "type": "number" + } + } + } + }, + "metadataGeneration": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "minLength": 1 }, - "order": { - "type": "number" + "model": { + "type": "string", + "minLength": 1 + }, + "thinkingOptionId": { + "type": "string", + "minLength": 1 } }, + "required": ["provider"], "additionalProperties": false } } }, "additionalProperties": false - }, - "features": { + } + }, + "additionalProperties": false + }, + "features": { + "type": "object", + "properties": { + "dictation": { "type": "object", "properties": { - "dictation": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "stt": { - "type": "object", - "properties": { - "provider": { - "allOf": [ - { - "type": "string" - }, - { - "type": "string", - "enum": ["openai", "local"] - } - ] - }, - "model": { - "type": "string", - "minLength": 1 - }, - "language": { - "type": "string", - "minLength": 1 - }, - "confidenceThreshold": { - "type": "number" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false + "enabled": { + "type": "boolean" }, - "voiceMode": { + "stt": { "type": "object", "properties": { - "enabled": { - "type": "boolean" + "provider": { + "type": "string" }, - "llm": { - "type": "object", - "properties": { - "provider": { - "type": "string" - }, - "model": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - }, - "stt": { - "type": "object", - "properties": { - "provider": { - "$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider" - }, - "model": { - "type": "string", - "minLength": 1 - }, - "language": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - }, - "turnDetection": { - "type": "object", - "properties": { - "provider": { - "$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider" - } - }, - "additionalProperties": false - }, - "tts": { - "type": "object", - "properties": { - "provider": { - "$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider" - }, - "model": { - "type": "string", - "minLength": 1 - }, - "voice": { - "type": "string", - "enum": ["alloy", "echo", "fable", "onyx", "nova", "shimmer"] - }, - "speakerId": { - "type": "integer" - }, - "speed": { - "type": "number" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "webUi": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "distDir": { + "model": { "type": "string", "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + }, + "confidenceThreshold": { + "type": "number" } }, "additionalProperties": false @@ -500,7 +579,107 @@ }, "additionalProperties": false }, - "log": { + "voiceMode": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "llm": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "stt": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "turnDetection": { + "type": "object", + "properties": { + "provider": { + "type": "string" + } + }, + "additionalProperties": false + }, + "tts": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "voice": { + "type": "string", + "enum": ["alloy", "echo", "fable", "onyx", "nova", "shimmer"] + }, + "speakerId": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "speed": { + "type": "number" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "webUi": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "distDir": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "log": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": ["trace", "debug", "info", "warn", "error", "fatal"] + }, + "format": { + "type": "string", + "enum": ["pretty", "json"] + }, + "console": { "type": "object", "properties": { "level": { @@ -510,42 +689,32 @@ "format": { "type": "string", "enum": ["pretty", "json"] + } + }, + "additionalProperties": false + }, + "file": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": ["trace", "debug", "info", "warn", "error", "fatal"] }, - "console": { + "path": { + "type": "string", + "minLength": 1 + }, + "rotate": { "type": "object", "properties": { - "level": { - "$ref": "#/definitions/PaseoConfigV1/properties/log/properties/level" - }, - "format": { - "$ref": "#/definitions/PaseoConfigV1/properties/log/properties/format" - } - }, - "additionalProperties": false - }, - "file": { - "type": "object", - "properties": { - "level": { - "$ref": "#/definitions/PaseoConfigV1/properties/log/properties/level" - }, - "path": { + "maxSize": { "type": "string", "minLength": 1 }, - "rotate": { - "type": "object", - "properties": { - "maxSize": { - "type": "string", - "minLength": 1 - }, - "maxFiles": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "additionalProperties": false + "maxFiles": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 } }, "additionalProperties": false @@ -557,5 +726,6 @@ "additionalProperties": false } }, - "$schema": "http://json-schema.org/draft-07/schema#" + "additionalProperties": false, + "title": "PaseoConfigV1" }