diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml index c156ec0a16..cc23e1c96c 100644 --- a/.github/ci-paths.yml +++ b/.github/ci-paths.yml @@ -35,6 +35,9 @@ hub: server: - "packages/server/**" + - "runtimes/fixture/**" + - "packages/workspace-helper/**" + - "packages/workspace-runtime-contract/**" - "packages/app/e2e/support/fixtures/recording.*" desktop: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7fa5c814c..427b366b85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,9 @@ jobs: - name: Lint run: npm run lint + - name: Enforce workspace runtime ownership boundaries + run: npm run check:workspace-runtime-boundaries && node --test scripts/workspace-runtime-boundaries.test.mjs + typecheck: name: typecheck needs: changes @@ -129,6 +132,8 @@ jobs: - name: Verify public package contents run: | npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol + npm pack --dry-run --ignore-scripts --workspace=@getpaseo/workspace-runtime-contract + npm pack --dry-run --ignore-scripts --workspace=@getpaseo/workspace-helper npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server @@ -163,6 +168,18 @@ jobs: - name: Build server dependencies run: npm run build:server-deps + - name: Test workspace runtime command contract package + if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} + run: npm test --workspace=@getpaseo/workspace-runtime-contract && npm test --workspace=@getpaseo/workspace-helper + + - name: Prove the command runtime fixture is independently installable + if: ${{ runner.os == 'Linux' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }} + run: node --test scripts/workspace-runtime-standalone.test.mjs + + - name: Prove clean server builds own public prerequisites + if: ${{ runner.os == 'Linux' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }} + run: node --test scripts/server-clean-build.test.mjs + - name: Run server tests if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} run: npm run test --workspace=@getpaseo/server diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index dc5221fa6e..79d5d1c584 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -16,6 +16,9 @@ on: - "packages/app/**" - "packages/cli/**" - "packages/client/**" + - "packages/desktop/**" + - "packages/workspace-helper/**" + - "packages/workspace-runtime-contract/**" - "packages/expo-two-way-audio/**" - "packages/highlight/**" - "packages/plugin/**" diff --git a/.oxlintrc.json b/.oxlintrc.json index 5fe29499a5..ab2232edb7 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -84,6 +84,144 @@ "max-nested-callbacks": ["error", { "max": 3 }] }, "overrides": [ + { + "files": ["packages/server/src/**/*.{ts,tsx}"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "**/workspace-runtime/internal/**", + "**/workspace-runtime/command/internal/**" + ], + "message": "Workspace runtime internals are owned by their public entry points." + }, + { + "group": ["**/workspace-helper/internal/**"], + "message": "Normal callers consume WorkspaceFiles from workspace-helper/index. Only the workspace-runtime parent integration and workspace-helper tests may assemble helper clients." + }, + { + "group": [ + "**/workspace-runtime/integration/**", + "**/workspace-runtime/git-observation/internal/**" + ], + "message": "Git callers consume workspace-runtime/git-observation/index. Only runtime internals may assemble shared observation identity, placement, helpers, and lifecycle." + } + ] + } + ] + } + }, + { + "files": [ + "packages/server/src/server/workspace-runtime/index.ts", + "packages/server/src/server/workspace-runtime/command/index.ts", + "packages/server/src/server/workspace-runtime/command/internal/**/*.{ts,tsx}", + "packages/server/src/server/workspace-runtime/internal/**/*.{ts,tsx}", + "packages/server/src/server/workspace-helper/**/*.{ts,tsx}", + "packages/server/src/server/workspace-runtime/git-observation/**/*.{ts,tsx}", + "packages/server/src/server/workspace-runtime/internal/service.ts" + ], + "rules": { + "no-restricted-imports": "off" + } + }, + { + "files": [ + "packages/server/src/server/session/files/**/*.{ts,tsx}", + "packages/server/src/server/session/git-mutation/**/*.{ts,tsx}", + "packages/server/src/server/session/provider/**/*.{ts,tsx}", + "packages/server/src/server/session/workspace-git-observer/**/*.{ts,tsx}", + "packages/server/src/server/session/workspace-provisioning/**/*.{ts,tsx}", + "packages/server/src/server/session/workspace-scripts/**/*.{ts,tsx}" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { "name": "child_process", "message": "Use the bound workspace runtime." }, + { "name": "node:child_process", "message": "Use the bound workspace runtime." }, + { "name": "fs", "message": "Use the bound workspace runtime." }, + { "name": "node:fs", "message": "Use the bound workspace runtime." }, + { "name": "fs/promises", "message": "Use the bound workspace runtime." }, + { "name": "node:fs/promises", "message": "Use the bound workspace runtime." }, + { "name": "module", "message": "Dynamic loaders bypass workspace ownership." }, + { + "name": "node:module", + "message": "Dynamic loaders bypass workspace ownership." + }, + { "name": "which", "message": "Use the bound workspace runtime." } + ], + "patterns": [ + { + "group": ["**/utils/run-git-command.{js,ts}"], + "message": "Use the bound workspace runtime." + }, + { + "group": [ + "**/workspace-runtime/internal/**", + "**/workspace-runtime/command/internal/**", + "**/workspace-runtime/git-observation/internal/**", + "**/workspace-helper/internal/**" + ], + "message": "Use the owning public entry point." + } + ] + } + ] + } + }, + { + "files": [ + "packages/server/src/server/session/files/**/*.test.{ts,tsx}", + "packages/server/src/server/session/git-mutation/**/*.test.{ts,tsx}", + "packages/server/src/server/session/provider/**/*.test.{ts,tsx}", + "packages/server/src/server/session/workspace-git-observer/**/*.test.{ts,tsx}", + "packages/server/src/server/session/workspace-provisioning/**/*.test.{ts,tsx}", + "packages/server/src/server/session/workspace-scripts/**/*.test.{ts,tsx}" + ], + "rules": { + "no-restricted-imports": "off" + } + }, + { + "files": ["packages/fixture-workspace-runtime/**/*.{js,mjs,ts,tsx}"], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "module", + "message": "The external runtime may not construct dynamic module loaders." + }, + { + "name": "node:module", + "message": "The external runtime may not construct dynamic module loaders." + }, + { + "name": "@getpaseo/server", + "message": "The external runtime may consume only the published command contract." + } + ], + "patterns": [ + { + "group": [ + "@getpaseo/server/**", + "**/packages/server/**", + "**/workspace-runtime/**/internal/**", + "**/workspace-helper/**/internal/**" + ], + "message": "The external runtime may consume only the published command contract." + } + ] + } + ] + } + }, { "files": ["packages/app/src/**/*.{ts,tsx}"], "rules": { diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index cd2591ed49..7670a91f96 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -22,6 +22,8 @@ RUN set -eux; \ npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \ + npm pack --workspace=@getpaseo/workspace-runtime-contract --pack-destination /tmp/paseo-packs; \ + npm pack --workspace=@getpaseo/workspace-helper --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@paseo/plugin --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \ diff --git a/docs/architecture.md b/docs/architecture.md index 2946da8b40..4fc6d7db61 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,6 +75,8 @@ not retain non-Git directories. | `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation | | `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK | | `server/agent/providers/` | Provider adapters (see "Agent providers" below) | +| `server/workspace-runtime/` | Runtime-neutral workspace lifecycle, execution, files, Git observation | +| `server/provider-probe/` | Invisible runtime-bound workspaces for pre-creation provider discovery | | `server/relay-transport.ts` | Outbound relay connection with E2E encryption | | `server/schedule/` | Cron-based scheduled agents | @@ -85,6 +87,19 @@ agent timeline types, provider config schemas, and other values shared by daemon and clients. Server, app, CLI, and `@getpaseo/client` all depend on this package; it does not depend on the server. +### Workspace runtime packages + +`packages/workspace-runtime-contract` owns the versioned command-runtime lifecycle and exec wire +contract. `packages/workspace-helper` owns the official confined files/watch/command-resolution +executable and its typed binding. The daemon depends on those two packages and registers command +runtimes without importing an implementation. + +`runtimes/fixture` is a private generic contract fixture. Runtime implementations live in their own +repositories and depend on published versions of the two public workspace packages. CLI, Desktop, +server, release workflows, and the daemon image do not depend on, bundle, publish, or register an +implementation. Tests invoke registered runtimes through their public command, never through source +imports. + ### `packages/client` — Daemon client library and SDK facade Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient` diff --git a/docs/data-model.md b/docs/data-model.md index 7908f39e28..3d0aa71741 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -16,19 +16,23 @@ names, and workspace foreign keys. Attached workspaces are independently refresh from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects are observed too. -The workspace registry model defines placement once: initial directory/worktree construction, -mutable reconciliation fields, and the persisted-to-wire checkout projection. Its update policy -preserves `displayName` and `baseBranch`. `WorkspaceProvisioningService` owns the corresponding -registry writes, so directory opens, agent imports, and worktree creation all enter through that -service instead of constructing records independently. The workspace record is then the durable -placement authority: `cwd` is the exact execution directory, while `worktreeRoot` is the backing -checkout root. They intentionally differ for an exact subproject inside a worktree. Archive, -restore, branch auto-name, and descriptor flows consume those persisted facts rather than -rediscovering ownership from a directory that may already be gone. Reconciliation may refresh -mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`. -Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing -`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing -checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`. +The workspace registry model defines public placement once: initial directory/worktree +construction, mutable reconciliation fields, and the persisted-to-wire projection. +`WorkspaceProvisioningService` owns the corresponding registry writes, so directory opens, agent +imports, and worktree creation do not construct records independently. + +For a runtime-selected workspace, `runtime.runtimeId` is the only persisted runtime identity. `cwd` +is required compatibility and presentation data: an adopted local directory, the actual host +worktree path, or a runtime-local path such as Docker's `/workspace`. It is not globally unique and +never selects a runtime. `hostVisiblePath` is the separate optional capability used by editor and +file-manager actions. Private roots, revisions, execution domains, container and volume IDs, +labels, supervisor state, and ownership metadata are recovered from driver `inspect(workspaceId)` +and are never written to the workspace record. + +Records without `runtime` remain the narrow legacy placement boundary. Their `cwd`, `worktreeRoot`, +and `mainRepoRoot` continue to support old local/worktree behavior until classified. Selected +worktree records retain user-visible worktree placement metadata, but lifecycle and deletion are +owned by the runtime driver. Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries. @@ -423,7 +427,8 @@ Array of workspace records. A workspace is a specific working directory within a | ------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `workspaceId` | `string` | Opaque stable identifier (`wks_`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. | | `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership | -| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup | +| `cwd` | `string` | Client compatibility projection. Runtime-selected server operations address the workspace by `workspaceId`; the driver owns its execution root. | +| `hostVisiblePath` | `string \| null` | Explicit host-path capability for editor and file-manager integrations. Null for Docker and remote placement. | | `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification | | `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. | | `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". | @@ -432,13 +437,19 @@ Array of workspace records. A workspace is a specific working directory within a | `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees | | `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` | | `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` | +| `runtime` | `{ runtimeId: string } \| undefined` | Immutable selected workspace runtime. Runtime roots, revisions, execution domains, and resource identifiers are never stored in the workspace record. | | `createdAt` | `string` (ISO 8601) | | | `updatedAt` | `string` (ISO 8601) | | | `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | | `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. | | `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" | -> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity. +> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Runtime-selected filesystem, Git, terminal, provider, and script operations use it as their server-side address. `cwd` remains on the wire for compatibility and display. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity. + +For a runtime-selected workspace, archive pauses the runtime and sets `archivedAt`; it does not remove +the runtime's files. Restore resumes the same runtime before clearing `archivedAt`. Permanent project +removal destroys each selected runtime before removing its workspace record. Records without +`runtime` remain on the legacy local/worktree lifecycle path until they are explicitly re-created. `projectId` is still a real FK: workspace records should have a matching project record. Read-only history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot diff --git a/docs/development.md b/docs/development.md index 05d61b8c2e..fb24e8f298 100644 --- a/docs/development.md +++ b/docs/development.md @@ -304,7 +304,7 @@ Worktrees inherit committed Git state only; uncommitted source-checkout changes `worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array of commands. Both run sequentially. -Lifecycle commands run in the worktree through a stable script shell: `bash` +For runtime-selected workspaces, lifecycle commands run inside the selected runtime through a stable script shell. Legacy worktrees use `bash` resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on Windows. They inherit the daemon environment plus Paseo's lifecycle variables; login and interactive shell startup files are not loaded, and Bash's `BASH_ENV` diff --git a/docs/docker.md b/docs/docker.md index 15e22081bb..fdb7a01a83 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -10,7 +10,7 @@ The image source lives in [`docker/`](../docker/). The official image: -- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs +- builds `@getpaseo/server`, `@getpaseo/cli`, and their public workspace packages from source-built workspace tarballs - runs the daemon as the non-root `paseo` user - listens on `0.0.0.0:6767` inside the container - enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true` diff --git a/docs/glossary.md b/docs/glossary.md index 4dd156761b..c4c4c19662 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -5,8 +5,10 @@ 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 selected environment where a workspace's files, terminals, Git operations, scripts, provider discovery, and agents run. UI: "Runtime" on New Workspace. Configured runtimes use their configured label. Code: `WorkspaceRuntimeService`; the remembered choice is `FormPreferences.runtimeId`. +- **Local** — Built-in runtime that adopts the selected host directory. UI: "Local". It does not mean “the current client device”; it means the daemon host. +- **Docker** — Built-in runtime that materializes committed Git content into a runtime-owned Docker volume. UI: "Docker". Host bind mounts may provide configuration outside `/workspace`, but never replace project materialization. - **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 +19,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** — Built-in runtime that creates a Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. 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/docs/providers.md b/docs/providers.md index 4e199e3b0b..5d591e7b51 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -103,7 +103,9 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star ## Provider Snapshot Refresh Contract -The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory. +The daemon keeps provider snapshots per bound workspace, with separate legacy cwd and semantic global scopes. New Workspace first ensures one invisible provider-probe workspace for the selected `(projectId, runtimeId)`, then reads the snapshot through that workspace binding. The probe is created through `WorkspaceRuntimeService`, so availability, models, and modes come from the selected runtime. Probe creation never runs repository setup. Old clients and bare-cwd callers keep the cwd snapshot path; do not replace or remove it. + +Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` after the workspace runtime has bound its private placement. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory. `ProviderSnapshotManager` owns one refresh deadline per provider. The deadline starts before the availability check and covers that check plus the complete catalog probe. Providers that make @@ -114,13 +116,13 @@ that were still active when the deadline expired. Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own. -Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal. +Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and workspace identity, or per cwd for the legacy path, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal. Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace. Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path. -Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only global. +Boundary tests should assert observable behavior: New Workspace never falls back to a host cwd after selecting a runtime; cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one workspace; settings refresh wipes all scopes but immediately refreshes only global. --- diff --git a/docs/qa.md b/docs/qa.md index 25176f80fe..242ea4f4dc 100644 --- a/docs/qa.md +++ b/docs/qa.md @@ -21,6 +21,8 @@ Evidence is something someone else can look at: - A video of the interaction - Logs, requests, responses +Workspace-runtime changes include the selected Local/Worktree parity cases, the shared common-Git watcher assertion, the fixture Desktop journey, and the blocking Docker matrix when Docker behavior changes. Provider-probe changes also include lifecycle/restart evidence and proof that probe records, processes, watchers, containers, and volumes do not leak. + Redact what you need to, keep the technical details. If an agent did the work, submit its raw output. A summary drops the details someone else needs to check it. ## Does it work well diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 3e19b6cc97..0257859dcf 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-MgMvQC+EGJ8gGosqel8oC88pMzf2Mtetd2D8Yg5VFAM= +sha256-YJpDgaoaBS1lCJIVhxBIfeNelyYRsJMcN/HgEO4KehM= diff --git a/package-lock.json b/package-lock.json index 64ddb2ca79..aca4206551 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,10 @@ "packages/relay", "packages/website", "packages/desktop", - "packages/cli" + "packages/cli", + "packages/workspace-runtime-contract", + "packages/workspace-helper", + "runtimes/fixture" ], "devDependencies": { "@types/ws": "^8.5.14", @@ -6256,6 +6259,10 @@ "resolved": "packages/expo-two-way-audio", "link": true }, + "node_modules/@getpaseo/fixture-workspace-runtime": { + "resolved": "runtimes/fixture", + "link": true + }, "node_modules/@getpaseo/highlight": { "resolved": "packages/highlight", "link": true @@ -6276,6 +6283,14 @@ "resolved": "packages/website", "link": true }, + "node_modules/@getpaseo/workspace-helper": { + "resolved": "packages/workspace-helper", + "link": true + }, + "node_modules/@getpaseo/workspace-runtime-contract": { + "resolved": "packages/workspace-runtime-contract", + "link": true + }, "node_modules/@gorhom/bottom-sheet": { "version": "5.2.14", "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.14.tgz", @@ -28470,6 +28485,16 @@ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "license": "MIT" }, + "node_modules/node-pty": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.15.tgz", + "integrity": "sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -38847,6 +38872,8 @@ "@getpaseo/highlight": "0.4.0", "@getpaseo/protocol": "0.4.0", "@getpaseo/relay": "0.4.0", + "@getpaseo/workspace-helper": "0.4.0", + "@getpaseo/workspace-runtime-contract": "0.4.0", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", @@ -39695,16 +39722,6 @@ "node": "20 || >=22" } }, - "packages/server/node_modules/node-pty": { - "version": "1.2.0-beta.15", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.15.tgz", - "integrity": "sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^7.1.0" - } - }, "packages/server/node_modules/openai": { "version": "6.44.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.44.0.tgz", @@ -41501,6 +41518,43 @@ "optional": true } } + }, + "packages/workspace-helper": { + "name": "@getpaseo/workspace-helper", + "version": "0.4.0", + "dependencies": { + "zod": "^4.4.3" + }, + "bin": { + "paseo-workspace-helper": "dist/executable.mjs" + }, + "devDependencies": { + "@types/node": "^20.9.0", + "typescript": "^5.9.3", + "vitest": "^4.1.6" + } + }, + "packages/workspace-runtime-contract": { + "name": "@getpaseo/workspace-runtime-contract", + "version": "0.4.0", + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "typescript": "^5.9.3" + } + }, + "runtimes/fixture": { + "name": "@getpaseo/fixture-workspace-runtime", + "version": "0.4.0", + "dependencies": { + "@getpaseo/workspace-helper": "0.4.0", + "@getpaseo/workspace-runtime-contract": "0.4.0", + "node-pty": "1.2.0-beta.15" + }, + "bin": { + "paseo-fixture-workspace-runtime": "src/index.mjs" + } } } } diff --git a/package.json b/package.json index 840beb831b..eee6171ca6 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,10 @@ "packages/relay", "packages/website", "packages/desktop", - "packages/cli" + "packages/cli", + "packages/workspace-runtime-contract", + "packages/workspace-helper", + "runtimes/fixture" ], "scripts": { "dev": "npm run dev:server", @@ -55,8 +58,11 @@ "build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol", "build:client": "npm run build --workspace=@getpaseo/client", "build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client", - "build:server-deps": "concurrently --kill-others-on-fail --names highlight,plugin,relay,client --prefix-colors yellow,green,blue,cyan \"npm run build:highlight\" \"npm run build:plugin\" \"npm run build:relay\" \"npm run build:client\"", - "build:server-deps:clean": "npm run build:highlight:clean && npm run build:plugin:clean && npm run build:relay:clean && npm run build:client:clean", + "build:workspace-runtime-contract": "npm run build --workspace=@getpaseo/workspace-runtime-contract", + "build:workspace-helper": "npm run build --workspace=@getpaseo/workspace-helper", + "build:workspace-runtime-fixture": "npm run build --workspace=@getpaseo/fixture-workspace-runtime", + "build:server-deps": "npm run build:workspace-runtime-contract && npm run build:workspace-helper && concurrently --kill-others-on-fail --names highlight,plugin,relay,client --prefix-colors yellow,green,blue,cyan \"npm run build:highlight\" \"npm run build:plugin\" \"npm run build:relay\" \"npm run build:client\"", + "build:server-deps:clean": "npm run build:clean --workspace=@getpaseo/workspace-runtime-contract && npm run build:clean --workspace=@getpaseo/workspace-helper && npm run build:highlight:clean && npm run build:plugin:clean && npm run build:relay:clean && npm run build:client: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", @@ -72,6 +78,7 @@ "format:check": "oxfmt --check .", "format:check:files": "oxfmt --check", "lint": "oxlint", + "check:workspace-runtime-boundaries": "node scripts/check-workspace-runtime-boundaries.mjs", "lint:fix": "oxlint --fix", "knip": "knip", "acp:version-drift": "node scripts/check-acp-catalog-version-drift.mjs", @@ -102,11 +109,11 @@ "version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major", "version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next", "version:all:promote": "node scripts/set-release-version.mjs --mode promote", - "release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli", - "release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public", - "release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta", - "release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public", - "release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta", + "release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/workspace-runtime-contract && npm run typecheck --workspace=@getpaseo/workspace-helper && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/workspace-runtime-contract && npm pack --dry-run --workspace=@getpaseo/workspace-helper && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli", + "release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/workspace-runtime-contract --access public && npm publish --dry-run --workspace=@getpaseo/workspace-helper --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public", + "release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/workspace-runtime-contract --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/workspace-helper --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta", + "release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/workspace-runtime-contract --access public && npm publish --workspace=@getpaseo/workspace-helper --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public", + "release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/workspace-runtime-contract --access public --tag beta && npm publish --workspace=@getpaseo/workspace-helper --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta", "release:push": "node scripts/push-current-release-tag.mjs", "release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:publish:beta && npm run release:push", "release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:publish:beta && npm run release:push", diff --git a/packages/app/e2e/browser/agent-consecutive-turns.spec.ts b/packages/app/e2e/browser/agent-consecutive-turns.spec.ts index 8032da7939..872f286c23 100644 --- a/packages/app/e2e/browser/agent-consecutive-turns.spec.ts +++ b/packages/app/e2e/browser/agent-consecutive-turns.spec.ts @@ -809,6 +809,7 @@ test("keeps the first prompt of a new agent in place through authoritative hydra ); }); const gate = await installDaemonWebSocketGate(page); + gate.setAgentStreamItemSuppressed("assistant_message", true); await gotoWorkspace(page, workspace.workspaceId); await clickNewChat(page); await expectComposerVisible(page); @@ -816,14 +817,20 @@ test("keeps the first prompt of a new agent in place through authoritative hydra const prompt = "Delay synthetic user message by 300ms."; await attachImageFromMenu(page, FIRST_PROMPT_IMAGE); await expectAttachmentPill(page, "composer-image-attachment-pill"); + gate.holdNextServerMessage("fetch_agent_timeline_response"); await recordTurnFrames(page, prompt); await submitMessage(page, prompt); const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt }).first(); await expect(submittedRow).toBeVisible(); - await gate.waitForServerMessage("fetch_agent_timeline_response"); + await gate.waitForHeldServerMessage("fetch_agent_timeline_response"); + gate.truncateHeldTimelineAfterLast("user_message"); + gate.setServerMessageSuppressed("fetch_agent_timeline_response", true); + gate.releaseHeldServerMessage("fetch_agent_timeline_response"); await recordPaintsFor(page, 80); expectAtomicFirstPromptTransition(await stopTurnFrameRecording(page)); + gate.setServerMessageSuppressed("fetch_agent_timeline_response", false); + gate.setAgentStreamItemSuppressed("assistant_message", false); } finally { await workspace.cleanup(); } 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..077127b826 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)}`); @@ -134,10 +136,11 @@ test.describe("New workspace entry points", () => { sourceDirectory: "/tmp/missing-project", }); + await expect(page.getByRole("button", { name: "Retry" })).toBeVisible({ timeout: 30_000 }); 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 +249,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 +275,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 +287,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"]); } 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-launch-memory.spec.ts b/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts index dc15ed981d..cf755b47fe 100644 --- a/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts +++ b/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts @@ -21,13 +21,13 @@ import { type TerminalProfileSeed, } from "../support/helpers/new-workspace-launch"; -// `sleep` keeps the terminal alive long enough for the UI to attach and -// render output before the process exits — see new-workspace-launch-terminal.spec.ts. +// Loaded CI workers can take several seconds to materialize the runtime and attach the UI. +// Keep the fixture bounded but alive through that assertion window. const PROMPT_PROFILE: TerminalProfile = { id: "e2e-memory-profile", name: "Memory Profile", command: "/bin/sh", - args: ["-c", 'echo remembered: "$0"; sleep 10', "{{{prompt}}}"], + args: ["-c", 'echo remembered: "$0"; sleep 60', "{{{prompt}}}"], }; function hasLeftNewWorkspaceRoute(url: URL): boolean { 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/browser/worktree-restore.spec.ts b/packages/app/e2e/browser/worktree-restore.spec.ts index d8bac26fe4..8ede2e8c2c 100644 --- a/packages/app/e2e/browser/worktree-restore.spec.ts +++ b/packages/app/e2e/browser/worktree-restore.spec.ts @@ -59,15 +59,16 @@ test.describe("Worktree restore", () => { }); createdProjectIds.add(worktree.projectKey); createdWorktreeDirectories.add(worktree.workspaceDirectory); - const agents = await Promise.all( - Array.from({ length: options.agentCount ?? 1 }, () => - createIdleAgent(client, { + const agents = []; + for (let index = 0; index < (options.agentCount ?? 1); index += 1) { + agents.push( + await createIdleAgent(client, { cwd: worktree.workspaceDirectory, workspaceId: worktree.workspaceId, title: `${prefix}-${randomUUID().slice(0, 8)}`, }), - ), - ); + ); + } const agent = agents[0]; if (!agent) { throw new Error("Expected at least one archived-worktree agent"); @@ -369,10 +370,7 @@ test.describe("Worktree restore", () => { timeout: 30_000, }); await expect( - page.getByText( - "The archived workspace directory no longer exists and cannot be recreated.", - { exact: true }, - ), + page.getByText("The archived workspace runtime is missing.", { exact: true }), ).toBeVisible(); await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0); } finally { diff --git a/packages/app/e2e/support/helpers/e2e-worker.ts b/packages/app/e2e/support/helpers/e2e-worker.ts index 13c496d660..13c5b4b53a 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,74 @@ 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"); + const materializeRoot = path.join(paseoHome, "fixture-workspaces"); + const fixtureProviderSource = path.resolve( + __dirname, + "../../../../../runtimes/fixture/test/fixtures/workspace-runtime-acp-agent.mjs", + ); + await mkdir(stateDirectory, { recursive: true }); + await writeFile( + configPath, + `${JSON.stringify({ + ...existing, + version: 1, + agents: { + ...existing.agents, + providers: { + ...existing.agents?.providers, + claude: { ...existing.agents?.providers?.claude, enabled: false }, + codex: { ...existing.agents?.providers?.codex, enabled: false }, + copilot: { ...existing.agents?.providers?.copilot, enabled: false }, + opencode: { ...existing.agents?.providers?.opencode, enabled: false }, + pi: { ...existing.agents?.providers?.pi, enabled: false }, + "fixture-agent": { + extends: "acp", + label: "Fixture Agent", + command: [process.execPath, "./.paseo-fixture-agent.mjs"], + models: [{ id: "fixture-model", label: "Fixture Model", isDefault: true }], + enabled: true, + }, + }, + }, + workspaceRuntimes: { + ...existing.workspaceRuntimes, + fixture: { + type: "command", + label: "Fixture", + command: [ + process.execPath, + path.resolve(__dirname, "../../../../../runtimes/fixture/src/index.mjs"), + ], + options: { + stateDirectory, + materializeRoot, + fixtureProviderSource, + preserveSourceDisplayCwd: true, + }, + }, + "fixture-failure": { + type: "command", + label: "Fixture Failure", + command: [ + process.execPath, + path.resolve(__dirname, "../../../../../runtimes/fixture/src/index.mjs"), + ], + options: { + stateDirectory: path.join(paseoHome, "fixture-failure-runtime"), + failCreate: true, + createError: "Fixture probe creation failed", + }, + }, + }, + })}\n`, + ); +} + export async function startE2EWorker( workerIndex: number, options: { forkProviders?: string[] } = {}, @@ -154,6 +223,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..2486d36643 --- /dev/null +++ b/packages/app/e2e/support/helpers/new-workspace-runtime.ts @@ -0,0 +1,330 @@ +import { access, readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import { expect, type Page } 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; +} + +const SETUP_MARKER = "runtime-user-setup-ran.txt"; + +export async function seedGitProjectForRuntime( + options?: Parameters[1], +): Promise { + const repo = await createTempGitRepo("runtime-selector-", { + ...options, + paseoConfig: options?.paseoConfig ?? { + worktree: { setup: [`printf setup > ${SETUP_MARKER}`] }, + }, + }); + return seedRuntimeProject(repo); +} + +async function readProbeRecords(projectId: string): Promise> { + 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", "provider-probes.json"), "utf8"), + ) as Array<{ workspaceId: string; projectId: string }>; + return records.filter((record) => record.projectId === projectId); +} + +async function markerExists(root: string): Promise { + return access(path.join(root, SETUP_MARKER)).then( + () => true, + () => false, + ); +} + +export async function expectProbeSkippedProjectSetup(project: SeededRuntimeProject): Promise { + expect(await markerExists(project.sourceDirectory)).toBe(false); + const paseoHome = process.env.E2E_PASEO_HOME; + if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set"); + const probeIds = new Set( + (await readProbeRecords(project.projectId)).map((record) => record.workspaceId), + ); + const stateDirectory = path.join(paseoHome, "fixture-runtime"); + const stateFiles = await readdir(stateDirectory); + const states = await Promise.all( + stateFiles.map( + async (file) => + JSON.parse(await readFile(path.join(stateDirectory, file), "utf8")) as { + workspaceId: string; + root: string; + }, + ), + ); + const probe = states.find((state) => probeIds.has(state.workspaceId)); + expect(probe, "fixture probe runtime state").toBeDefined(); + expect(await markerExists(probe!.root)).toBe(false); +} + +export async function expectUserWorkspaceRanProjectSetup( + project: SeededRuntimeProject, +): Promise { + 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<{ projectId: string; workspaceId: string; runtime?: { runtimeId: string } }>; + const workspace = records.find( + (record) => record.projectId === project.projectId && record.runtime?.runtimeId === "fixture", + ); + expect(workspace, "fixture user workspace record").toBeDefined(); + const stateFiles = await readdir(path.join(paseoHome, "fixture-runtime")); + const states = await Promise.all( + stateFiles.map( + async (file) => + JSON.parse(await readFile(path.join(paseoHome, "fixture-runtime", file), "utf8")) as { + workspaceId: string; + root: string; + }, + ), + ); + const state = states.find((candidate) => candidate.workspaceId === workspace!.workspaceId); + expect(state, "fixture user runtime state").toBeDefined(); + await expect.poll(() => markerExists(state!.root), { timeout: 30_000 }).toBe(true); +} + +export async function expectProviderAvailable(page: Page, providerLabel: string): Promise { + const trigger = page.getByRole("button", { name: /Select model/ }); + await expect(trigger).toBeEnabled({ timeout: 30_000 }); + await trigger.click(); + await page.getByRole("dialog").last().getByRole("button", { name: "Back", exact: true }).click(); + await page.getByText(providerLabel, { exact: true }).click(); + await expect(page.getByRole("button", { name: `Open ${providerLabel} settings` })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); +} + +export async function selectRuntimeProviderModel( + page: Page, + input: { provider: string; model: string }, +): Promise { + const trigger = page.getByRole("button", { name: /Select model/ }); + await expect(trigger).toBeEnabled({ timeout: 30_000 }); + await trigger.click(); + const dialog = page.getByRole("dialog").last(); + await dialog.getByRole("button", { name: "Back", exact: true }).click(); + await dialog.getByText(input.provider, { exact: true }).click(); + const model = dialog.getByText(input.model, { exact: true }); + await expect(model).toBeVisible({ timeout: 30_000 }); + await model.click(); + await expect(trigger).toContainText(input.model); +} + +export async function expectRuntimeProviderUnavailable( + page: Page, + providerLabel: string, +): Promise { + const trigger = page.getByRole("button", { name: /Select model/ }); + await expect(trigger).toBeEnabled({ timeout: 30_000 }); + await trigger.click(); + const dialog = page.getByRole("dialog").last(); + await dialog.getByRole("button", { name: "Back", exact: true }).click(); + const providerId = providerLabel.toLowerCase().replaceAll(" ", ""); + const providerRow = dialog.getByTestId(`model-provider-${providerId}`); + await expect(providerRow).toBeVisible({ timeout: 30_000 }); + await expect(providerRow).toHaveText(`${providerLabel}Error`); + await page.keyboard.press("Escape"); +} + +export async function expectFixtureProviderUnavailable(page: Page): Promise { + const providerLabel = "Fixture Agent"; + const trigger = page.getByRole("button", { name: /Select model/ }); + await expect(trigger).toBeEnabled({ timeout: 30_000 }); + await trigger.click(); + await page.getByRole("dialog").last().getByRole("button", { name: "Back", exact: true }).click(); + await page.getByText(providerLabel, { exact: true }).click(); + await expect(page.getByRole("button", { name: `Open ${providerLabel} settings` })).toBeVisible(); + await expect(page.getByText(/^Fixture Model/u)).not.toBeVisible(); + await page.keyboard.press("Escape"); +} + +export async function expectProbeFailureWithRetry(page: Page, message: string): Promise { + await expect(page.getByRole("alert")).toContainText(message, { timeout: 30_000 }); + await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: /Select model/ })).toBeDisabled(); + await expect(page.getByText("Fixture Agent", { exact: true })).not.toBeVisible(); +} + +export async function retryFailedProbe(page: Page): Promise { + await page.getByRole("button", { name: "Retry", exact: true }).click(); +} + +export async function expectNoProbeInWorkspaceProjection( + page: Page, + project: SeededRuntimeProject, +): Promise { + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + try { + const probeIds = new Set( + (await readProbeRecords(project.projectId)).map((record) => record.workspaceId), + ); + const workspaces = await client.fetchWorkspaces({ filter: { projectId: project.projectId } }); + expect(workspaces.entries.some((workspace) => probeIds.has(workspace.id))).toBe(false); + for (const probeId of probeIds) { + await expect(page.getByTestId(`sidebar-workspace-${probeId}`)).toHaveCount(0); + } + } finally { + await client.close(); + } +} + +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 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, + timeoutMs: 120_000, + }); + 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 } }>; + const record = records.find((candidate) => candidate.workspaceId === workspace.workspaceId); + expect(record?.runtime).toEqual({ runtimeId }); + return workspace.workspaceId; + } finally { + await client.close(); + } +} + +export async function expectSelectedHostRuntimePlacement( + project: SeededRuntimeProject, + runtimeId: "local" | "worktree", + setupRan: boolean, +): Promise { + 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<{ + projectId: string; + cwd: string; + hostVisiblePath?: string | null; + runtime?: { runtimeId: string }; + }>; + const workspace = records.find( + (record) => record.projectId === project.projectId && record.runtime?.runtimeId === runtimeId, + ); + expect(workspace, `${runtimeId} selected workspace record`).toBeDefined(); + expect(workspace?.hostVisiblePath).toBe(workspace?.cwd); + if (setupRan) { + await expect.poll(() => markerExists(workspace!.cwd), { timeout: 30_000 }).toBe(true); + } else { + expect(await markerExists(workspace!.cwd)).toBe(false); + } +} + +export async function expectHostWorkspaceAffordances(page: Page): Promise { + await expect(page.getByRole("button", { name: "Open workspace in VS Code" })).toBeVisible(); + await page.getByRole("button", { name: "Choose editor" }).click(); + await expect(page.getByText("VS Code", { exact: true })).toBeVisible(); + await expect(page.getByText("Finder", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); +} diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index f53cb50c19..bc03bbe381 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -221,7 +221,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 }); @@ -319,35 +319,61 @@ 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 = "local" | "worktree"; + +const WORKSPACE_RUNTIME_LABEL: Record = { 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 { @@ -464,7 +490,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( @@ -476,6 +502,7 @@ export async function assertNewWorkspaceSidebarAndHeader( projectDisplayName: string; assertSidebarRow?: boolean; assertHeader?: boolean; + timeoutMs?: number; }, ): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> { // URL is the source of truth so concurrent sidebar rows cannot satisfy this. @@ -485,7 +512,7 @@ export async function assertNewWorkspaceSidebarAndHeader( const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId); return workspaceId && workspaceId !== input.previousWorkspaceId ? workspaceId : null; }, - { timeout: 60_000 }, + { timeout: input.timeoutMs ?? 60_000 }, ) .not.toBeNull(); diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index ff4aa01909..723a0ae4cb 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -143,6 +143,7 @@ export interface SeedDaemonClient { timeout?: number, ): Promise<{ status: string; final?: { lastError?: string | null } | null }>; archiveAgent(agentId: string): Promise<{ archivedAt: string }>; + cancelAgent(agentId: string): Promise; refreshAgent(agentId: string): Promise; fetchAgent(options: { agentId: string; @@ -173,6 +174,10 @@ export interface SeedDaemonClient { handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void, ): () => void; killTerminal(terminalId: string): Promise<{ error: string | null }>; + listWorkspaceScripts(workspaceId: string): Promise<{ + scripts: Array<{ lifecycle: "running" | "stopped"; scriptName: string }>; + }>; + stopWorkspaceScript(workspaceId: string, scriptName: string): Promise<{ error?: string | null }>; } export async function connectSeedClient(options?: { diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 58174eae19..e46dbe2cbe 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -650,6 +650,7 @@ const AgentStreamViewComponent = forwardRef ); }, - [agentId, client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot], + [ + agentId, + client, + context.workspaceId, + handleInlinePathPress, + resolvedServerId, + toast, + workspaceRoot, + ], ); const renderThoughtItem = useCallback( diff --git a/packages/app/src/assistant-image/acquisition-cache.test.ts b/packages/app/src/assistant-image/acquisition-cache.test.ts index ce1961b632..5eafebe8ef 100644 --- a/packages/app/src/assistant-image/acquisition-cache.test.ts +++ b/packages/app/src/assistant-image/acquisition-cache.test.ts @@ -94,6 +94,25 @@ describe("assistant image acquisition cache", () => { expect(laterMessage).not.toBe(first); }); + it("scopes same-cwd file acquisitions to the selected workspace", () => { + const first = createAssistantImageFileAcquisitionKey({ + serverId: "server", + workspaceId: "workspace-1", + occurrenceKey: "message:image", + cwd: "/workspace", + path: "screenshot.png", + }); + const second = createAssistantImageFileAcquisitionKey({ + serverId: "server", + workspaceId: "workspace-2", + occurrenceKey: "message:image", + cwd: "/workspace", + path: "screenshot.png", + }); + + expect(second).not.toBe(first); + }); + it("scopes persisted file previews to the rendered message occurrence", () => { const first = createAssistantImageFilePreviewAttachmentId({ serverId: "server-1", diff --git a/packages/app/src/assistant-image/acquisition-cache.ts b/packages/app/src/assistant-image/acquisition-cache.ts index f76f594b88..196ed71837 100644 --- a/packages/app/src/assistant-image/acquisition-cache.ts +++ b/packages/app/src/assistant-image/acquisition-cache.ts @@ -19,6 +19,7 @@ export function createAssistantImageOccurrenceKey(input: { export function createAssistantImageFilePreviewAttachmentId(input: { serverId?: string; + workspaceId?: string; occurrenceKey: string; mimeType: string; path: string; @@ -32,17 +33,18 @@ export function createAssistantImageFilePreviewAttachmentId(input: { size: input.size, modifiedAt: input.modifiedAt, contentLength: input.contentLength, - contentKey: `${input.serverId ?? "unknown-server"}:${input.occurrenceKey}`, + contentKey: `${input.serverId ?? "unknown-server"}:${input.workspaceId ?? "legacy-workspace"}:${input.occurrenceKey}`, }); } export function createAssistantImageFileAcquisitionKey(input: { serverId?: string; + workspaceId?: string; occurrenceKey: string; cwd: string; path: string; }): string { - return `file:${input.serverId ?? "unknown-server"}:${input.occurrenceKey}:${input.cwd}:${input.path}`; + return `file:${input.serverId ?? "unknown-server"}:${input.workspaceId ?? "legacy-workspace"}:${input.occurrenceKey}:${input.cwd}:${input.path}`; } export function createAssistantImageAcquisitionCache(input: { diff --git a/packages/app/src/assistant-image/file-acquisition.test.ts b/packages/app/src/assistant-image/file-acquisition.test.ts index 552609e75f..bf61409ae6 100644 --- a/packages/app/src/assistant-image/file-acquisition.test.ts +++ b/packages/app/src/assistant-image/file-acquisition.test.ts @@ -6,10 +6,10 @@ import { } from "./file-acquisition"; class MemoryFileAcquisitionPort implements AssistantImageFileAcquisitionPort { - readonly reads: Array<{ cwd: string; path: string }> = []; + readonly reads: Array<{ cwd: string; path: string; workspaceId?: string }> = []; - async readFile(cwd: string, path: string) { - this.reads.push({ cwd, path }); + async readFile(cwd: string, path: string, workspaceId?: string) { + this.reads.push({ cwd, path, ...(workspaceId ? { workspaceId } : {}) }); return { kind: "image" as const, path, @@ -38,6 +38,7 @@ describe("assistant image file acquisition", () => { const common = { resolution: { kind: "file_rpc" as const, cwd: "/workspace", path: "reconnect.png" }, serverId: "server", + workspaceId: "workspace-selected", occurrenceKey: "agent:message:reconnect-image", unavailableMessage: "Image unavailable", }; @@ -48,6 +49,23 @@ describe("assistant image file acquisition", () => { expect(disconnected?.key).toBe(connected?.key); await expect(disconnected?.locate()).rejects.toThrow("Image unavailable"); await expect(connected?.locate()).resolves.toMatchObject({ mimeType: "image/png" }); - expect(connectedPort.reads).toEqual([{ cwd: "/workspace", path: "reconnect.png" }]); + expect(connectedPort.reads).toEqual([ + { cwd: "/workspace", path: "reconnect.png", workspaceId: "workspace-selected" }, + ]); + }); + + it("keeps legacy file acquisition cwd-compatible when workspaceId is absent", async () => { + const port = new MemoryFileAcquisitionPort(); + const acquisition = createAssistantImageFileAcquisition({ + port, + resolution: { kind: "file_rpc", cwd: "/legacy", path: "preview.png" }, + serverId: "server", + occurrenceKey: "agent:message:legacy-image", + unavailableMessage: "Image unavailable", + }); + + await acquisition?.locate(); + + expect(port.reads).toEqual([{ cwd: "/legacy", path: "preview.png" }]); }); }); diff --git a/packages/app/src/assistant-image/file-acquisition.ts b/packages/app/src/assistant-image/file-acquisition.ts index f7a80e8536..9f1a3959b4 100644 --- a/packages/app/src/assistant-image/file-acquisition.ts +++ b/packages/app/src/assistant-image/file-acquisition.ts @@ -8,7 +8,7 @@ import { } from "./acquisition-cache"; export interface AssistantImageFileAcquisitionPort { - readFile(cwd: string, path: string): Promise; + readFile(cwd: string, path: string, workspaceId?: string): Promise; persist(input: { id: string; bytes: Uint8Array; @@ -26,6 +26,7 @@ export function createAssistantImageFileAcquisition(input: { port: AssistantImageFileAcquisitionPort | null; resolution: AssistantImageSourceResolution | null; serverId?: string; + workspaceId?: string; occurrenceKey: string; unavailableMessage: string; }): AssistantImageAcquisition | null { @@ -36,6 +37,7 @@ export function createAssistantImageFileAcquisition(input: { return { key: createAssistantImageFileAcquisitionKey({ serverId: input.serverId, + workspaceId: input.workspaceId, occurrenceKey: input.occurrenceKey, cwd: resolution.cwd, path: resolution.path, @@ -44,13 +46,14 @@ export function createAssistantImageFileAcquisition(input: { if (!port) { throw new Error(input.unavailableMessage); } - const file = await port.readFile(resolution.cwd, resolution.path); + const file = await port.readFile(resolution.cwd, resolution.path, input.workspaceId); if (file.kind !== "image") { throw new Error(input.unavailableMessage); } return await port.persist({ id: createAssistantImageFilePreviewAttachmentId({ serverId: input.serverId, + workspaceId: input.workspaceId, occurrenceKey: input.occurrenceKey, mimeType: file.mime, path: file.path || resolution.path, diff --git a/packages/app/src/assistant-image/use-assistant-image.ts b/packages/app/src/assistant-image/use-assistant-image.ts index 0c0818e8f1..725a9d7d93 100644 --- a/packages/app/src/assistant-image/use-assistant-image.ts +++ b/packages/app/src/assistant-image/use-assistant-image.ts @@ -82,6 +82,7 @@ interface UseAssistantImageInput { client?: DaemonClient | null; workspaceRoot?: string; serverId?: string; + workspaceId?: string; } type PreviewUrlState = @@ -377,6 +378,7 @@ export function useAssistantImage({ client, workspaceRoot, serverId, + workspaceId, }: UseAssistantImageInput): AssistantImageResult { const { t } = useTranslation(); const resolution = useMemo( @@ -387,7 +389,8 @@ export function useAssistantImage({ const fileAcquisition = useMemo(() => { const port: AssistantImageFileAcquisitionPort | null = client ? { - readFile: async (cwd, path) => await client.readFile(cwd, path), + readFile: async (cwd, path, selectedWorkspaceId) => + await client.readFile(cwd, path, undefined, selectedWorkspaceId), persist: persistAttachmentFromBytes, } : null; @@ -395,10 +398,11 @@ export function useAssistantImage({ port, resolution, serverId, + workspaceId, occurrenceKey, unavailableMessage: t("message.attachments.imagePreviewUnavailable"), }); - }, [client, occurrenceKey, resolution, serverId, t]); + }, [client, occurrenceKey, resolution, serverId, t, workspaceId]); const dataImageAcquisition = useMemo( () => createDataImageAcquisition({ source, dataImage }), [dataImage, source], 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/branch-switcher.tsx b/packages/app/src/components/branch-switcher.tsx index 13a17b9999..b4f6a42e97 100644 --- a/packages/app/src/components/branch-switcher.tsx +++ b/packages/app/src/components/branch-switcher.tsx @@ -6,15 +6,13 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import type { Theme } from "@/styles/theme"; import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useToast } from "@/contexts/toast-context"; import { useBranchSwitcher } from "@/hooks/use-branch-switcher"; interface BranchSwitcherProps { currentBranchName: string | null; serverId: string; - workspaceId: string; - workspaceDirectory: string | null; isGitCheckout: boolean; testID?: string; } @@ -29,23 +27,17 @@ const ThemedChevronDown = withUnistyles(ChevronDown); export function BranchSwitcher({ currentBranchName, serverId, - workspaceId, - workspaceDirectory, isGitCheckout, testID = "workspace-header-branch-switcher", }: BranchSwitcherProps) { const { t } = useTranslation(); const anchorRef = useRef(null); - const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const toast = useToast(); const queryClient = useQueryClient(); const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({ - client, normalizedServerId: serverId, - normalizedWorkspaceId: workspaceId, - workspaceDirectory, currentBranchName, isGitCheckout, isConnected, diff --git a/packages/app/src/components/compact-explorer-sidebar-host.tsx b/packages/app/src/components/compact-explorer-sidebar-host.tsx index f51f827fa0..f5903108c1 100644 --- a/packages/app/src/components/compact-explorer-sidebar-host.tsx +++ b/packages/app/src/components/compact-explorer-sidebar-host.tsx @@ -11,6 +11,11 @@ import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command"; import { isWeb } from "@/constants/platform"; +import { + bindSelectedWorkspaceGit, + WorkspaceGitBoundary, + type WorkspaceGitClient, +} from "@/git/workspace-git"; import { resolveCompactExplorerSidebarHostModel, type CompactExplorerSidebarHostModel, @@ -24,6 +29,62 @@ interface CompactExplorerOpenGestureSurfaceProps { const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y"; +function useCompactWorkspaceGit( + client: ReturnType, + workspaceId: string | null | undefined, + cwd: string | null | undefined, +): WorkspaceGitClient | null { + return useMemo(() => { + if (!client || !workspaceId || !cwd) { + return null; + } + return bindSelectedWorkspaceGit(client, { kind: "selected", workspaceId, cwd }); + }, [client, cwd, workspaceId]); +} + +function useCompactExplorerModel({ + enabled, + isExplorerOpen, + selection, + workspace, + isGit, + showMobileAgent, +}: { + enabled: boolean; + isExplorerOpen: boolean; + selection: Parameters[0]["selection"]; + workspace: Parameters[0]["workspace"]; + isGit: boolean; + showMobileAgent: () => void; +}): CompactExplorerSidebarHostModel | null { + const retainedModelRef = useRef(null); + const resolvedModel = useMemo( + () => + resolveCompactExplorerSidebarHostModel({ + previous: isExplorerOpen ? retainedModelRef.current : null, + selection, + workspace, + isGit, + }), + [isExplorerOpen, isGit, selection, workspace], + ); + useEffect(() => { + if (!selection) { + retainedModelRef.current = null; + if (enabled && isExplorerOpen) showMobileAgent(); + return; + } + if (!isExplorerOpen) { + retainedModelRef.current = null; + return; + } + if (resolvedModel) retainedModelRef.current = resolvedModel; + }, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]); + + if (!selection) return null; + return resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null); +} + function CompactExplorerOpenGestureSurface({ children, enabled, @@ -41,9 +102,10 @@ function CompactExplorerOpenGestureSurface({ ); } -function useActiveCompactExplorerSidebarModel( - enabled: boolean, -): CompactExplorerSidebarHostModel | null { +function useActiveCompactExplorerSidebarModel(enabled: boolean): { + model: CompactExplorerSidebarHostModel | null; + workspaceGit: WorkspaceGitClient | null; +} { const selection = useActiveWorkspaceSelection(); const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null); const isExplorerOpen = usePanelStore((state) => @@ -52,44 +114,32 @@ function useActiveCompactExplorerSidebarModel( const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const client = useHostRuntimeClient(selection?.serverId ?? ""); const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? ""); - const retainedModelRef = useRef(null); - const { checkoutQuery } = useWorkspaceCheckoutStatus({ + const workspaceGit = useCompactWorkspaceGit( client, + selection?.workspaceId, + workspace?.workspaceDirectory, + ); + const { checkoutQuery } = useWorkspaceCheckoutStatus({ + workspaceGit, isConnected, isRouteFocused: enabled && selection !== null, normalizedServerId: selection?.serverId ?? "", normalizedWorkspaceId: selection?.workspaceId ?? "", workspaceDirectory: workspace?.workspaceDirectory || null, }); - const resolvedModel = useMemo( - () => - resolveCompactExplorerSidebarHostModel({ - previous: isExplorerOpen ? retainedModelRef.current : null, - selection, - workspace, - isGit: checkoutQuery.data?.isGit ?? false, - }), - [checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace], - ); - - useEffect(() => { - if (!selection) { - retainedModelRef.current = null; - if (enabled && isExplorerOpen) { - showMobileAgent(); - } - return; - } - if (!isExplorerOpen) { - retainedModelRef.current = null; - return; - } - if (resolvedModel) { - retainedModelRef.current = resolvedModel; - } - }, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]); + const model = useCompactExplorerModel({ + enabled, + isExplorerOpen, + selection, + workspace, + isGit: checkoutQuery.data?.isGit ?? false, + showMobileAgent, + }); - return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null; + return { + model, + workspaceGit, + }; } interface CompactExplorerSidebarHostProps { @@ -98,7 +148,7 @@ interface CompactExplorerSidebarHostProps { } export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) { - const model = useActiveCompactExplorerSidebarModel(enabled); + const { model, workspaceGit } = useActiveCompactExplorerSidebarModel(enabled); const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused); @@ -137,20 +187,22 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore return ( <> {children} - {enabled && model ? ( - - ) : null} + + {enabled && model && workspaceGit ? ( + + ) : null} + ); } diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 9788776a2f..c5f874f52e 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -22,6 +22,7 @@ import { usePrPaneData, } from "@/git/pull-request-panel"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data"; import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store"; import { useToast } from "@/contexts/toast-context"; @@ -320,10 +321,10 @@ function ExplorerSidebarContent({ const { t } = useTranslation(); const toast = useToast(); const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right"); + const workspaceGit = useRequiredWorkspaceGit(); const canQueryPullRequest = isGit && Boolean(workspaceRoot); const prPane = usePrPaneData({ serverId, - cwd: workspaceRoot, enabled: canQueryPullRequest && isOpen, timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen, }); @@ -335,10 +336,13 @@ function ExplorerSidebarContent({ const prTabLabel = formatPrTabLabel(prPane.prNumber); const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh); const handlePrRetry = useCallback(() => { - refreshGitActions({ serverId, cwd: workspaceRoot }).catch((error) => { + refreshGitActions({ + serverId, + target: workspaceGit, + }).catch((error) => { toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); }); - }, [refreshGitActions, serverId, t, toast, workspaceRoot]); + }, [refreshGitActions, serverId, t, toast, workspaceGit]); const workspaceAttachmentScopeKey = useMemo( () => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }), [serverId, workspaceId, workspaceRoot], @@ -419,7 +423,7 @@ function ExplorerSidebarContent({ {resolvedTab === "changes" && ( @@ -436,6 +440,7 @@ function ExplorerSidebarContent({ {resolvedTab === "pr" && ( ({ @@ -82,6 +90,7 @@ function buildSessionsQueriesConfig(args: { throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected")); } return await client.fetchRecentProviderSessions({ + ...(workspaceId ? { workspaceId } : {}), ...(cwd ? { cwd } : {}), providers: [provider], limit: PER_PROVIDER_LIMIT, @@ -274,6 +283,7 @@ export function ImportSessionSheet({ const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, { cwd, + workspaceId, enabled: visible, }); const supportsWorkspaceTarget = useHostFeature(serverId, "importSessionWorkspaceTarget"); @@ -294,8 +304,8 @@ export function ImportSessionSheet({ ); const sessionsQueryRoot = useMemo( - () => ["recent-provider-sessions", cwd ?? null] as const, - [cwd], + () => ["recent-provider-sessions", workspaceId ?? null, cwd ?? null] as const, + [cwd, workspaceId], ); const queriesConfig = useMemo( @@ -306,9 +316,10 @@ export function ImportSessionSheet({ visible, client, cwd, + workspaceId, hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), }), - [providersToFetch, sessionsQueryRoot, visible, client, cwd, t], + [providersToFetch, sessionsQueryRoot, visible, client, cwd, workspaceId, t], ); const queries = useQueries({ queries: queriesConfig }); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 8058320f99..4719258a2c 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -736,6 +736,7 @@ interface AssistantMessageProps { message: string; timestamp: number; workspaceRoot?: string; + workspaceId?: string; serverId?: string; client?: DaemonClient | null; spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth"; @@ -799,6 +800,7 @@ function AssistantMarkdownImage({ hasLeadingContent, client, workspaceRoot, + workspaceId, serverId, }: { source: string; @@ -807,6 +809,7 @@ function AssistantMarkdownImage({ hasLeadingContent: boolean; client?: DaemonClient | null; workspaceRoot?: string; + workspaceId?: string; serverId?: string; }) { const containerStyle = useMemo>( @@ -821,6 +824,7 @@ function AssistantMarkdownImage({ occurrenceKey, client, workspaceRoot, + workspaceId, serverId, }); const binding = image.status === "failed" ? null : image.binding; @@ -1448,6 +1452,7 @@ export const AssistantMessage = memo(function AssistantMessage({ message, timestamp: _timestamp, workspaceRoot, + workspaceId, serverId, client, spacing = "default", @@ -1885,12 +1890,22 @@ export const AssistantMessage = memo(function AssistantMessage({ hasLeadingContent={hasLeadingContent} client={client} workspaceRoot={workspaceRoot} + workspaceId={workspaceId} serverId={serverId} /> ); }, }; - }, [client, fileLinkActions, markdownParser, occurrenceKey, phase, serverId, workspaceRoot]); + }, [ + client, + fileLinkActions, + markdownParser, + occurrenceKey, + phase, + serverId, + workspaceId, + workspaceRoot, + ]); const blocks = useMemo(() => splitMarkdownBlocks(message), [message]); const keyedBlocks = useMemo( 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/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 68de9d73df..5988106bba 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -654,7 +654,7 @@ function WorkspaceRowRightGroup({ isPinned?: boolean; onTogglePin?: () => void; }) { - const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath; + const workspacePath = workspace.hostVisiblePath; const { t } = useTranslation(); const trailing = useSidebarWorkspaceTrailing(); const showShortcut = showShortcutBadge && shortcutNumber !== null; @@ -1156,7 +1156,7 @@ function WorkspaceRowInner({ archiveShortcutKeys={archiveShortcutKeys} isPinned={isPinned} onTogglePin={onTogglePin} - openInFileManagerPath={workspace.workspaceDirectory} + openInFileManagerPath={workspace.hostVisiblePath} disabled={isArchiving} aria-selected={selected} accessibilityRole="button" diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index a1ca1324e6..3d463ba058 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -882,7 +882,7 @@ function StatusWorkspaceRowInnerContent({ archiveShortcutKeys={archiveShortcutKeys} isPinned={isPinned} onTogglePin={onTogglePin} - openInFileManagerPath={workspace.workspaceDirectory} + openInFileManagerPath={workspace.hostVisiblePath} disabled={isArchiving} accessibilityRole="button" accessibilityState={accessibilityState} diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index 87c28cf64f..40f88dd24f 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -338,7 +338,7 @@ function WorkspaceRowBody({ archiveStatus={archiveStatus} archivePendingLabel={archivePendingLabel} archiveShortcutKeys={archiveShortcutKeys} - openInFileManagerPath={workspace.workspaceDirectory} + openInFileManagerPath={workspace.hostVisiblePath} disabled={isArchiving} aria-selected={selected} accessibilityRole="button" diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 0ca4b2f8b7..adb49f0870 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -31,6 +31,7 @@ import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import type { MessagePayload } from "@/composer/types"; +import { PreWorkspaceComposerGitOwner } from "@/git/workspace-git"; function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { @@ -429,27 +430,29 @@ export function WorkspaceSetupDialog() { testID="workspace-setup-dialog" desktopMaxWidth={640} > - - - + + + + + {errorMessage ? {errorMessage} : null} diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 5494de61b9..e14b353762 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -349,6 +349,7 @@ function pickDesktopModel({ type AgentControlsSlice = { provider: string; cwd: string | null; + workspaceId: string | undefined; runtimeModelId: string | null; model: string | null | undefined; features: AgentFeature[] | undefined; @@ -368,6 +369,7 @@ function selectAgentControlsSlice( return { provider: currentAgent.provider, cwd: currentAgent.cwd, + workspaceId: currentAgent.workspaceId, runtimeModelId: currentAgent.runtimeInfo?.model ?? null, model: currentAgent.model, features: currentAgent.features, @@ -376,6 +378,16 @@ function selectAgentControlsSlice( }; } +function providerSnapshotTarget(agent: AgentControlsSlice): { + cwd: string | null | undefined; + workspaceId: string | undefined; +} { + return { + cwd: agent?.cwd, + workspaceId: agent?.workspaceId, + }; +} + function resolveSnapshotSelectedEntry( snapshotEntries: ReturnType["entries"], agentProvider: string | undefined, @@ -564,7 +576,7 @@ function ControlledAgentControls({ } }, [updateDensityForWidth]); - const modelDisabled = disabled; + const modelDisabled = [disabled, isModelLoading].some(Boolean); const comboboxProviderOptions = useMemo( () => toComboboxOptions(providerOptions), @@ -1481,7 +1493,7 @@ export const AgentControls = memo(function AgentControls({ isRefreshing: snapshotIsRefreshing, refresh: refreshSnapshot, refetchIfStale: refetchSnapshotIfStale, - } = useProvidersSnapshot(serverId, { cwd: agent?.cwd }); + } = useProvidersSnapshot(serverId, providerSnapshotTarget(agent)); const snapshotSelectedEntry = useMemo( () => resolveSnapshotSelectedEntry(snapshotEntries, agent?.provider), diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx index 6acd2dec1f..0155d9d820 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -253,6 +253,7 @@ export function useLiveAgentModeControl( return { provider: agent.provider, cwd: agent.cwd, + workspaceId: agent.workspaceId, currentModeId: agent.currentModeId, }; }), @@ -265,7 +266,10 @@ export function useLiveAgentModeControl( const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const { updatePreferences } = useFormPreferences(); const toast = useToast(); - const { entries: snapshotEntries } = useProvidersSnapshot(serverId, { cwd: slice?.cwd }); + const { entries: snapshotEntries } = useProvidersSnapshot(serverId, { + cwd: slice?.cwd, + workspaceId: slice?.workspaceId, + }); const providerDefinitions = useMemo(() => { if (!slice?.provider) return []; diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index a579064e0b..9ebb834328 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -5,6 +5,7 @@ import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; import { useAgentFormState, type CreateAgentInitialValues, + type UseAgentFormStateOptions, type UseAgentFormStateResult, } from "@/hooks/use-agent-form-state"; import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features"; @@ -28,6 +29,9 @@ type AttachmentUpdater = | ((prev: UserComposerAttachment[]) => UserComposerAttachment[]); interface AgentInputDraftComposerOptions { + workspaceId?: string | null; + providerSnapshotCwd?: string | null; + isTargetDaemonReady?: boolean; initialServerId: string | null; initialValues?: CreateAgentInitialValues; initialFeatureValues?: Record; @@ -63,15 +67,24 @@ export interface AgentInputDraft { composerState: DraftComposerState | null; } -export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft { - const composerOptions = input.composer ?? null; - const formState = useAgentFormState({ +function buildAgentFormStateOptions( + composerOptions: AgentInputDraftComposerOptions | null, +): UseAgentFormStateOptions { + return { initialServerId: composerOptions?.initialServerId ?? null, initialValues: composerOptions?.initialValues, isVisible: composerOptions?.isVisible ?? false, isCreateFlow: true, onlineServerIds: composerOptions?.onlineServerIds ?? [], - }); + workspaceId: composerOptions?.workspaceId, + providerSnapshotCwd: composerOptions?.providerSnapshotCwd, + isTargetDaemonReady: composerOptions?.isTargetDaemonReady, + }; +} + +export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft { + const composerOptions = input.composer ?? null; + const formState = useAgentFormState(buildAgentFormStateOptions(composerOptions)); const draftKey = useMemo( () => resolveDraftKey({ diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 0293d7c3e9..277d851154 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -365,6 +365,7 @@ export function WorkspaceDraftAgentTab({ const draftInput = useAgentInputDraft({ draftKey: draftStoreKey, composer: { + workspaceId, initialServerId: serverId, initialValues: draftInitialValues, initialFeatureValues: draftSetup?.featureValues, diff --git a/packages/app/src/composer/github/auto-attach.test.tsx b/packages/app/src/composer/github/auto-attach.test.tsx index 2abb55ba36..ad1158ae4b 100644 --- a/packages/app/src/composer/github/auto-attach.test.tsx +++ b/packages/app/src/composer/github/auto-attach.test.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, renderHook } from "@testing-library/react"; import React, { type ReactNode } from "react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { describe, expect, it, vi } from "vitest"; import type { UserComposerAttachment } from "@/attachments/types"; import type { ForgeSearchClient } from "@/git/use-forge-search-query"; @@ -49,11 +49,12 @@ const issue202: ForgeSearchItem = { }; interface SearchCall { - cwd: string; query: string; limit?: number; } +type TestSearchClient = Pick; + interface HarnessInput { initialAttachments?: UserComposerAttachment[]; initialCwd?: string; @@ -72,7 +73,7 @@ function githubPayload(items: ForgeSearchItem[], requestId: string): ForgeSearch }; } -function createSearchClient(items: ForgeSearchItem[]): ForgeSearchClient & { calls: SearchCall[] } { +function createSearchClient(items: ForgeSearchItem[]): TestSearchClient & { calls: SearchCall[] } { const calls: SearchCall[] = []; return { calls, @@ -104,21 +105,28 @@ function createWrapper() { }; } -function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) { +function useHarness(client: TestSearchClient, input: HarnessInput = {}) { const [text, setText] = useState(input.initialText ?? ""); const [searchClient, setSearchClient] = useState(client); const [workingDirectory, setWorkingDirectory] = useState(input.initialCwd ?? cwd); const [attachments, setAttachments] = useState( input.initialAttachments ?? [], ); + const boundSearchClient = useMemo( + () => ({ + ...searchClient, + queryIdentity: ["selected", "workspace-1"] as const, + cwd: workingDirectory, + }), + [searchClient, workingDirectory], + ); const autoAttach = useComposerGithubAutoAttach({ text, remoteUrl: input.remote ?? remoteUrl, attachments, - client: searchClient, + client: boundSearchClient, isConnected: true, serverId: "server-1", - cwd: workingDirectory, setAttachments, onPullRequestDetected: input.onPullRequestDetected, onPullRequestAdded: input.onPullRequestAdded, @@ -161,7 +169,7 @@ describe("useComposerGithubAutoAttach", () => { expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]); expect(result.current.isResolving).toBe(false); - expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]); + expect(client.calls).toEqual([{ query: "101", limit: 20 }]); vi.useRealTimers(); }); @@ -235,8 +243,8 @@ describe("useComposerGithubAutoAttach", () => { { kind: "forge_issue", item: issue202 }, ]); expect(client.calls).toEqual([ - { cwd, query: "101", limit: 20 }, - { cwd, query: "202", limit: 20 }, + { query: "101", limit: 20 }, + { query: "202", limit: 20 }, ]); vi.useRealTimers(); }); @@ -245,7 +253,7 @@ describe("useComposerGithubAutoAttach", () => { vi.useFakeTimers(); const firstLookup = deferred(); const secondLookup = deferred(); - const client: ForgeSearchClient = { + const client: TestSearchClient = { searchForge: vi .fn() .mockReturnValueOnce(firstLookup.promise) @@ -288,7 +296,7 @@ describe("useComposerGithubAutoAttach", () => { vi.useFakeTimers(); const firstLookup = deferred(); const secondLookup = deferred(); - const client: ForgeSearchClient = { + const client: TestSearchClient = { searchForge: vi .fn() .mockReturnValueOnce(firstLookup.promise) @@ -357,8 +365,8 @@ describe("useComposerGithubAutoAttach", () => { expect(onPullRequestAdded.mock.calls).toEqual([[pr202], [pr101]]); expect(client.calls).toEqual([ - { cwd, query: "202", limit: 20 }, - { cwd, query: "101", limit: 20 }, + { query: "202", limit: 20 }, + { query: "101", limit: 20 }, ]); vi.useRealTimers(); }); @@ -367,7 +375,7 @@ describe("useComposerGithubAutoAttach", () => { vi.useFakeTimers(); const firstLookup = deferred(); const secondLookup = deferred(); - const client: ForgeSearchClient = { + const client: TestSearchClient = { searchForge: vi .fn() .mockReturnValueOnce(firstLookup.promise) @@ -419,7 +427,7 @@ describe("useComposerGithubAutoAttach", () => { vi.useFakeTimers(); const firstLookup = deferred(); const secondLookup = deferred(); - const client: ForgeSearchClient = { + const client: TestSearchClient = { searchForge: vi .fn() .mockReturnValueOnce(firstLookup.promise) @@ -459,7 +467,7 @@ describe("useComposerGithubAutoAttach", () => { it("keeps resolving when a pending pull request URL is removed and re-added", async () => { vi.useFakeTimers(); const lookup = deferred(); - const client: ForgeSearchClient = { + const client: TestSearchClient = { searchForge: vi.fn().mockReturnValue(lookup.promise), }; const onPullRequestAdded = vi.fn(); @@ -500,7 +508,7 @@ describe("useComposerGithubAutoAttach", () => { vi.useFakeTimers(); const firstLookup = deferred(); const secondLookup = deferred(); - const client: ForgeSearchClient = { + const client: TestSearchClient = { searchForge: vi .fn() .mockReturnValueOnce(firstLookup.promise) @@ -545,7 +553,7 @@ describe("useComposerGithubAutoAttach", () => { it("stays resolving when an unrelated attachment is added during lookup", async () => { vi.useFakeTimers(); const lookup = deferred(); - const client: ForgeSearchClient = { + const client: TestSearchClient = { searchForge: vi.fn().mockReturnValue(lookup.promise), }; const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() }); @@ -578,7 +586,7 @@ describe("useComposerGithubAutoAttach", () => { it("stops resolving when an in-flight PR URL is removed", async () => { vi.useFakeTimers(); const lookup = deferred(); - const client: ForgeSearchClient = { + const client: TestSearchClient = { searchForge: vi.fn().mockReturnValue(lookup.promise), }; const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() }); @@ -598,7 +606,7 @@ describe("useComposerGithubAutoAttach", () => { it("ignores a lookup that finishes after the target changes", async () => { vi.useFakeTimers(); const lookup = deferred(); - const client: ForgeSearchClient = { + const client: TestSearchClient = { searchForge: vi.fn().mockReturnValue(lookup.promise), }; const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() }); @@ -625,7 +633,7 @@ describe("useComposerGithubAutoAttach", () => { it("accepts a lookup after the transport client is replaced for the same target", async () => { vi.useFakeTimers(); const lookup = deferred(); - const firstClient: ForgeSearchClient = { + const firstClient: TestSearchClient = { searchForge: vi.fn().mockReturnValue(lookup.promise), }; const replacementClient = createSearchClient([pr101]); diff --git a/packages/app/src/composer/github/auto-attach.ts b/packages/app/src/composer/github/auto-attach.ts index 5cb19e47f2..6b1d6f5706 100644 --- a/packages/app/src/composer/github/auto-attach.ts +++ b/packages/app/src/composer/github/auto-attach.ts @@ -10,7 +10,12 @@ import { type SetStateAction, } from "react"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; -import { buildForgeSearchQueryOptions, type ForgeSearchClient } from "@/git/use-forge-search-query"; +import { + buildForgeSearchQueryOptions, + forgeSearchClientIdentity, + type ForgeSearchClient, +} from "@/git/use-forge-search-query"; +import { workspaceGitQueryIdentitiesEqual } from "@/git/workspace-git"; import { extractGithubRefs, type GithubRef } from "@/utils/github-refs"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "../actions"; @@ -24,7 +29,6 @@ interface ComposerGithubAutoAttachInput { client: ForgeSearchClient | null; isConnected: boolean; serverId: string; - cwd: string; supportsForgeSearch?: boolean; setAttachments: Dispatch>; onPullRequestDetected?: () => void; @@ -50,7 +54,7 @@ export function useComposerGithubAutoAttach( const removedRefKeysRef = useRef(new Set()); const presentPullRequestKeysRef = useRef(new Set()); const activeLookupsRef = useRef(new Set()); - const previousTargetRef = useRef({ serverId: params.serverId, cwd: params.cwd }); + const previousTargetRef = useRef({ serverId: params.serverId, client: params.client }); const [resolvingRefCounts, setResolvingRefCounts] = useState>( () => new Map(), ); @@ -79,7 +83,7 @@ export function useComposerGithubAutoAttach( hasClient, params.isConnected, params.serverId, - params.cwd, + params.client, ]); useEffect(() => { @@ -143,7 +147,7 @@ export function useComposerGithubAutoAttach( hasClient, params.isConnected, params.serverId, - params.cwd, + params.client, queryClient, ]); @@ -236,15 +240,25 @@ function suppressRefsCarriedAcrossTargets({ removedRefKeys, }: { params: ComposerGithubAutoAttachInput; - previousTargetRef: RefObject<{ serverId: string; cwd: string }>; + previousTargetRef: RefObject<{ + serverId: string; + client: ForgeSearchClient | null; + }>; removedRefKeys: Set; }): void { const previous = previousTargetRef.current; const targetChanged = - previous.cwd.trim().length > 0 && - params.cwd.trim().length > 0 && - (previous.serverId !== params.serverId || previous.cwd !== params.cwd); - previousTargetRef.current = { serverId: params.serverId, cwd: params.cwd }; + previous.client !== null && + params.client !== null && + previous.client.cwd.trim().length > 0 && + params.client.cwd.trim().length > 0 && + (previous.serverId !== params.serverId || + !workspaceGitQueryIdentitiesEqual( + forgeSearchClientIdentity(previous.client), + forgeSearchClientIdentity(params.client), + ) || + previous.client.cwd !== params.client.cwd); + previousTargetRef.current = { serverId: params.serverId, client: params.client }; if (!targetChanged) return; for (const ref of extractGithubRefs(params.text, params.remoteUrl)) { @@ -444,7 +458,7 @@ function refsReadyForLookup({ removedRefKeys: Set; activeLookups: ReadonlySet; }): GithubRef[] { - if (!params.client || !params.isConnected || params.cwd.trim().length === 0) { + if (!params.client || !params.isConnected || params.client.cwd.trim().length === 0) { return []; } @@ -476,7 +490,6 @@ async function fetchGithubRefSearch({ buildForgeSearchQueryOptions({ client: snapshot.client, serverId: snapshot.serverId, - cwd: snapshot.cwd, query: String(ref.number), supportsForgeSearch: snapshot.supportsForgeSearch, enabled: true, @@ -499,7 +512,13 @@ function isSameLookupTarget( ): boolean { return ( initial.serverId === current.serverId && - initial.cwd === current.cwd && + (initial.client === null || current.client === null + ? initial.client === current.client + : workspaceGitQueryIdentitiesEqual( + forgeSearchClientIdentity(initial.client), + forgeSearchClientIdentity(current.client), + )) && + initial.client?.cwd === current.client?.cwd && initial.remoteUrl === current.remoteUrl ); } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 3315411a3b..5423f8ba1c 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -121,6 +121,7 @@ import { useIsDictationReady } from "@/hooks/use-is-dictation-ready"; import { useForgeSearchQuery } from "@/git/use-forge-search-query"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; +import { useWorkspaceGit } from "@/git/workspace-git"; import { getForgePresentation } from "@/git/forge"; import { ForgeBrandIcon } from "@/git/forge-icon"; import { useComposerGithubAutoAttach } from "./github/auto-attach"; @@ -855,6 +856,8 @@ interface ComposerProps { submitIcon?: "arrow" | "return"; /** Externally controlled loading state. When true, disables the submit button. */ isSubmitLoading?: boolean; + /** Disables submission without locking draft editing or attachments. */ + isSubmitDisabled?: boolean; /** When true, waits for pasted GitHub links to resolve before enabling submit. */ waitForGithubAutoAttachOnSubmit?: boolean; submitBehavior?: "clear" | "preserve-and-lock"; @@ -1066,6 +1069,7 @@ export function Composer({ submitButtonTestID, submitIcon = "arrow", isSubmitLoading = false, + isSubmitDisabled = false, waitForGithubAutoAttachOnSubmit = false, submitBehavior = "clear", blurOnSubmit = false, @@ -1149,7 +1153,8 @@ export function Composer({ onOpenWorkspaceAttachment, }); const setSelectedAttachments = onChangeAttachments; - const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd }); + const checkoutStatusQuery = useCheckoutStatusQuery({ serverId }); + const workspaceGitClient = useWorkspaceGit(); const supportsForgeSearch = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true, ); @@ -1157,10 +1162,9 @@ export function Composer({ text: userInput, remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status), attachments, - client, + client: workspaceGitClient, isConnected, serverId, - cwd, supportsForgeSearch, setAttachments: setSelectedAttachments, onPullRequestDetected: onGithubPrDetected, @@ -1918,16 +1922,14 @@ export function Composer({ // until the forge-specific picker or attachment presentation is visible. const { forge } = useCheckoutPrStatusQuery({ serverId, - cwd, enabled: isConnected && cwd.trim().length > 0 && (isGithubPickerOpen || hasGithubAttachment), }); const forgePresentation = useMemo(() => getForgePresentation(forge), [forge]); const githubSearchQueryTrimmed = githubSearchQuery.trim(); const githubSearchResultsQuery = useForgeSearchQuery({ - client, + client: workspaceGitClient, serverId, - cwd, query: githubSearchQueryTrimmed, supportsForgeSearch, enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd), @@ -2145,8 +2147,10 @@ export function Composer({ const isSubmitLoadingVisible = isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0; - const isSubmitDisabled = - isSubmitLoadingVisible || (waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving); + const isSubmitActionDisabled = + isSubmitDisabled || + isSubmitLoadingVisible || + (waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving); // Disable drops while submitting/uploading: the submit path clears and restores attachments, // so a drop in that window would be lost or land on a locked draft. `disabled` hides the @@ -2205,7 +2209,7 @@ export function Composer({ submitButtonAccessibilityLabel={submitButtonAccessibilityLabel} submitButtonTestID={submitButtonTestID} submitIcon={submitIcon} - isSubmitDisabled={isSubmitDisabled} + isSubmitDisabled={isSubmitActionDisabled} isSubmitLoading={isSubmitLoadingVisible} preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"} attachments={selectedAttachments} 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 b30b2a66ca..33bdd1dbc5 100644 --- a/packages/app/src/create-agent-preferences/preferences.test.ts +++ b/packages/app/src/create-agent-preferences/preferences.test.ts @@ -61,12 +61,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 () => { @@ -212,36 +212,49 @@ describe("create agent preferences", () => { expect(parseFormPreferences({ provider: "codex", surprise: true })).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("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("preserves legacy favourites across preference writes until host migration", async () => { const favoriteModels = [{ provider: "claude", modelId: "opus" }]; const storage = new FakeCreateAgentPreferenceStorage({ stored: { favoriteModels } }); const preferences = new CreateAgentPreferencesService(storage); - const save = preferences.update({ isolation: "worktree" }); + const save = preferences.update({ runtimeId: "worktree" }); await storage.nextWrite(); storage.finishOldestWrite(); await save; - expect(storage.savedPreferences()).toEqual({ favoriteModels, isolation: "worktree" }); + expect(storage.savedPreferences()).toEqual({ favoriteModels, runtimeId: "worktree" }); }); - it("treats stored preferences without an isolation choice as undefined", () => { - expect(parseFormPreferences({ provider: "codex" }).isolation).toBeUndefined(); + it("treats stored preferences without a runtime choice as undefined", () => { + expect(parseFormPreferences({ provider: "codex" }).runtimeId).toBeUndefined(); }); 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 bdade894e8..699740d497 100644 --- a/packages/app/src/create-agent-preferences/preferences.ts +++ b/packages/app/src/create-agent-preferences/preferences.ts @@ -16,7 +16,7 @@ export interface FormPreferences { provider?: string; providerPreferences?: Record; favoriteModels?: Array<{ provider: string; modelId: string }>; - isolation?: "local" | "worktree"; + runtimeId?: string; launchTarget?: LaunchTarget; } @@ -46,7 +46,7 @@ export const FormPreferencesSchema = z.strictObject({ }), ) .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(), @@ -58,6 +58,9 @@ const LegacyProviderPreferencesSchema = z.strictObject({ thinkingOptionId: z.string().optional(), }); +const CurrentStoredFormPreferencesSchema = FormPreferencesSchema.extend({ + isolation: z.enum(["local", "worktree"]).optional(), +}); const LegacyFormPreferencesSchema = z .strictObject({ workingDir: z.string().optional(), @@ -86,7 +89,11 @@ const LegacyFormPreferencesSchema = z }); export const StoredFormPreferencesSchema: z.ZodType = z.union([ - FormPreferencesSchema, + CurrentStoredFormPreferencesSchema.transform(({ isolation, ...preferences }) => + preferences.runtimeId !== undefined || isolation === undefined + ? preferences + : { ...preferences, runtimeId: isolation }, + ), LegacyFormPreferencesSchema, ]); diff --git a/packages/app/src/data/provider-snapshot-cache.test.ts b/packages/app/src/data/provider-snapshot-cache.test.ts index f92e7e9bf9..ea608e035f 100644 --- a/packages/app/src/data/provider-snapshot-cache.test.ts +++ b/packages/app/src/data/provider-snapshot-cache.test.ts @@ -4,6 +4,7 @@ import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; import { z } from "zod"; import { createProviderSnapshotCache, type ProviderSnapshotCache } from "./provider-snapshot-cache"; +import type { ProviderSnapshotCacheScope } from "./provider-snapshot-cache"; const SNAPSHOT_KEY_PREFIX = "@paseo/provider-snapshot/v1:"; const SNAPSHOT_INDEX_KEY = "@paseo/provider-snapshot-index/v1"; @@ -12,6 +13,14 @@ const SnapshotIndexSchema = z.object({ entries: z.array(z.object({ key: z.string(), bytes: z.number(), writtenAt: z.string() })), }); +function legacyScope(cwd: string | null): ProviderSnapshotCacheScope { + return { type: "legacy-cwd", cwd }; +} + +function snapshotKey(serverId: string, cwd: string | null): string { + return `${SNAPSHOT_KEY_PREFIX}${JSON.stringify([serverId, legacyScope(cwd)])}`; +} + function createStorage(maxSnapshotBytes = Number.POSITIVE_INFINITY) { const values = new Map(); const stats = { getAllKeysCalls: 0 }; @@ -132,7 +141,7 @@ function writeSnapshot( ): Promise { return cache.write({ serverId: "server-1", - cwd: input.cwd, + scope: legacyScope(input.cwd), hash: input.label, generatedAt: input.generatedAt, compactSnapshot: compactProviderSnapshot(snapshotEntries(input.label)), @@ -161,12 +170,12 @@ describe("provider snapshot cache", () => { await cache.write({ serverId: "server-1", - cwd: "/repo", + scope: { type: "legacy-cwd", cwd: "/repo" }, hash: "snapshot-hash", generatedAt: "2026-08-04T00:00:00.000Z", compactSnapshot: compactProviderSnapshot(entries), }); - const cached = await cache.read("server-1", "/repo"); + const cached = await cache.read("server-1", { type: "legacy-cwd", cwd: "/repo" }); expect(cached?.entries).toEqual(entries); expect(cached?.entries[0]?.models?.[0]?.thinkingOptions).toBe( @@ -177,9 +186,12 @@ describe("provider snapshot cache", () => { it("discards an invalid cache record", async () => { const storage = createStorage(); const cache = createProviderSnapshotCache(storage); - storage.values.set('@paseo/provider-snapshot/v1:["server-1","/repo"]', "not json"); + storage.values.set( + '@paseo/provider-snapshot/v1:["server-1",{"type":"legacy-cwd","cwd":"/repo"}]', + "not json", + ); - await expect(cache.read("server-1", "/repo")).resolves.toBeNull(); + await expect(cache.read("server-1", { type: "legacy-cwd", cwd: "/repo" })).resolves.toBeNull(); expect([...storage.values.keys()].some((key) => key.startsWith(SNAPSHOT_KEY_PREFIX))).toBe( false, ); @@ -193,29 +205,29 @@ describe("provider snapshot cache", () => { await Promise.all([ cache.write({ serverId: "server-1", - cwd: "/oldest", + scope: legacyScope("/oldest"), hash: "oldest", generatedAt: "2026-08-01T00:00:00.000Z", compactSnapshot: compactProviderSnapshot(snapshotEntries("oldest")), }), cache.write({ serverId: "server-1", - cwd: "/middle", + scope: legacyScope("/middle"), hash: "middle", generatedAt: "2026-08-02T00:00:00.000Z", compactSnapshot: compactProviderSnapshot(snapshotEntries("middle")), }), cache.write({ serverId: "server-1", - cwd: "/newest", + scope: legacyScope("/newest"), hash: "newest", generatedAt: "2026-08-03T00:00:00.000Z", compactSnapshot: compactProviderSnapshot(snapshotEntries("newest")), }), ]); - await expect(cache.read("server-1", "/oldest")).resolves.toBeNull(); - await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull(); + await expect(cache.read("server-1", legacyScope("/oldest"))).resolves.toBeNull(); + await expect(cache.read("server-1", legacyScope("/newest"))).resolves.not.toBeNull(); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); expectIndexMatchesSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); @@ -228,14 +240,14 @@ describe("provider snapshot cache", () => { await cache.write({ serverId: "server-1", - cwd: "/current", + scope: legacyScope("/current"), hash: "current", generatedAt: "2026-08-03T00:00:00.000Z", compactSnapshot: compactProviderSnapshot(snapshotEntries("current")), }); expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false); - await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); + await expect(cache.read("server-1", legacyScope("/current"))).resolves.not.toBeNull(); expectIndexMatchesSnapshots(storage.values); }); @@ -244,7 +256,7 @@ describe("provider snapshot cache", () => { storage.values.set('@paseo/provider-snapshot/v1:["server-1","/legacy"]', "legacy"); const cache = createProviderSnapshotCache(storage); - await expect(cache.read("server-1", "/legacy")).resolves.toBeNull(); + await expect(cache.read("server-1", legacyScope("/legacy"))).resolves.toBeNull(); expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false); expectIndexMatchesSnapshots(storage.values); @@ -257,7 +269,7 @@ describe("provider snapshot cache", () => { const cache = createProviderSnapshotCache(storage); await Promise.all([ - cache.read("server-1", "/legacy"), + cache.read("server-1", legacyScope("/legacy")), writeSnapshot(cache, { cwd: "/current", label: "current", @@ -265,7 +277,7 @@ describe("provider snapshot cache", () => { }), ]); - await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); + await expect(cache.read("server-1", legacyScope("/current"))).resolves.not.toBeNull(); expectIndexMatchesSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); }); @@ -286,7 +298,7 @@ describe("provider snapshot cache", () => { generatedAt: "2026-08-02T00:00:00.000Z", }); - await expect(cache.read("server-1", "/repo")).resolves.toMatchObject({ + await expect(cache.read("server-1", legacyScope("/repo"))).resolves.toMatchObject({ hash: "replacement-is-larger", }); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); @@ -312,7 +324,7 @@ describe("provider snapshot cache", () => { generatedAt: "2026-08-02T00:00:00.000Z", }); - await expect(tinyCache.read("server-1", "/repo")).resolves.toBeNull(); + await expect(tinyCache.read("server-1", legacyScope("/repo"))).resolves.toBeNull(); expect(snapshotBytes(storage.values)).toBe(0); expectIndexMatchesSnapshots(storage.values); }); @@ -330,7 +342,7 @@ describe("provider snapshot cache", () => { }); expect(storage.values.has(`${SNAPSHOT_KEY_PREFIX}legacy`)).toBe(false); - await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); + await expect(cache.read("server-1", legacyScope("/current"))).resolves.not.toBeNull(); expectIndexMatchesSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); }); @@ -360,7 +372,7 @@ describe("provider snapshot cache", () => { generatedAt: "2026-08-02T00:00:00.000Z", }); - await expect(cache.read("server-1", "/emoji")).resolves.toBeNull(); + await expect(cache.read("server-1", legacyScope("/emoji"))).resolves.toBeNull(); expectIndexMatchesSnapshots(storage.values); }); @@ -379,7 +391,7 @@ describe("provider snapshot cache", () => { await Promise.all(writes); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); - await expect(cache.read("server-1", "/repo-249")).resolves.not.toBeNull(); + await expect(cache.read("server-1", legacyScope("/repo-249"))).resolves.not.toBeNull(); expectIndexMatchesSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); }); @@ -394,7 +406,7 @@ describe("provider snapshot cache", () => { label: "stable", generatedAt: "2026-08-01T00:00:00.000Z", }); - const failedKey = `${SNAPSHOT_KEY_PREFIX}["server-1","/failed"]`; + const failedKey = snapshotKey("server-1", "/failed"); storage.failNext("setItem", timing, failedKey); await writeSnapshot(cache, { @@ -409,8 +421,8 @@ describe("provider snapshot cache", () => { label: "next", generatedAt: "2026-08-03T00:00:00.000Z", }); - await expect(restarted.read("server-1", "/stable")).resolves.not.toBeNull(); - await expect(restarted.read("server-1", "/next")).resolves.not.toBeNull(); + await expect(restarted.read("server-1", legacyScope("/stable"))).resolves.not.toBeNull(); + await expect(restarted.read("server-1", legacyScope("/next"))).resolves.not.toBeNull(); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(2_000); expectIndexMatchesSnapshots(storage.values); }, @@ -436,7 +448,7 @@ describe("provider snapshot cache", () => { const restarted = createProviderSnapshotCache(storage, { maxBytes: 2_000 }); await expect( - restarted.read("server-1", "/orphan-after-index-failure"), + restarted.read("server-1", legacyScope("/orphan-after-index-failure")), ).resolves.not.toBeNull(); expectIndexMatchesSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(2); @@ -482,7 +494,7 @@ describe("provider snapshot cache", () => { expect(storage.values.get("@paseo/unrelated")).toBe("keep-me"); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); expectIndexMatchesSnapshots(storage.values); - await expect(restarted.read("server-1", "/final")).resolves.not.toBeNull(); + await expect(restarted.read("server-1", legacyScope("/final"))).resolves.not.toBeNull(); }); it("evicts before writing when storage has no temporary headroom", async () => { @@ -506,7 +518,7 @@ describe("provider snapshot cache", () => { generatedAt: "2026-08-03T00:00:00.000Z", }); - await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull(); + await expect(cache.read("server-1", legacyScope("/newest"))).resolves.not.toBeNull(); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); expectIndexMatchesSnapshots(storage.values); }); @@ -515,12 +527,12 @@ describe("provider snapshot cache", () => { "recovers when invalid-record cleanup fails %s deletion", async (timing) => { const storage = createStorage(); - const key = `${SNAPSHOT_KEY_PREFIX}["server-1","/invalid"]`; + const key = snapshotKey("server-1", "/invalid"); storage.values.set(key, "invalid"); storage.failNext("multiRemove", timing); const cache = createProviderSnapshotCache(storage); - await expect(cache.read("server-1", "/invalid")).resolves.toBeNull(); + await expect(cache.read("server-1", legacyScope("/invalid"))).resolves.toBeNull(); await writeSnapshot(cache, { cwd: "/recovered", label: "recovered", @@ -528,8 +540,37 @@ describe("provider snapshot cache", () => { }); expect(storage.values.has(key)).toBe(false); - await expect(cache.read("server-1", "/recovered")).resolves.not.toBeNull(); + await expect(cache.read("server-1", legacyScope("/recovered"))).resolves.not.toBeNull(); expectIndexMatchesSnapshots(storage.values); }, ); + + it("keeps selected workspaces with the same cwd structurally isolated", async () => { + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage); + const snapshot = (provider: "claude" | "codex") => + compactProviderSnapshot([{ provider, status: "ready", enabled: true, models: [] }]); + + await cache.write({ + serverId: "server-1", + scope: { type: "workspace", workspaceId: "workspace-a" }, + hash: "hash-a", + generatedAt: "2026-08-11T00:00:00.000Z", + compactSnapshot: snapshot("claude"), + }); + await cache.write({ + serverId: "server-1", + scope: { type: "workspace", workspaceId: "workspace-b" }, + hash: "hash-b", + generatedAt: "2026-08-11T00:00:01.000Z", + compactSnapshot: snapshot("codex"), + }); + + await expect( + cache.read("server-1", { type: "workspace", workspaceId: "workspace-a" }), + ).resolves.toMatchObject({ hash: "hash-a" }); + await expect( + cache.read("server-1", { type: "workspace", workspaceId: "workspace-b" }), + ).resolves.toMatchObject({ hash: "hash-b" }); + }); }); diff --git a/packages/app/src/data/provider-snapshot-cache.ts b/packages/app/src/data/provider-snapshot-cache.ts index ed4823a39c..a441ce19dc 100644 --- a/packages/app/src/data/provider-snapshot-cache.ts +++ b/packages/app/src/data/provider-snapshot-cache.ts @@ -56,11 +56,15 @@ export interface CachedProviderSnapshot extends StoredProviderSnapshot { entries: ProviderSnapshotEntry[]; } +export type ProviderSnapshotCacheScope = + | { type: "workspace"; workspaceId: string } + | { type: "legacy-cwd"; cwd: string | null }; + export interface ProviderSnapshotCache { - read(serverId: string, cwd: string | null): Promise; + read(serverId: string, scope: ProviderSnapshotCacheScope): Promise; write(input: { serverId: string; - cwd: string | null; + scope: ProviderSnapshotCacheScope; hash: string; generatedAt: string; compactSnapshot: CompactProviderSnapshot; @@ -74,8 +78,8 @@ export class ProviderSnapshotCacheMissError extends Error { } } -function cacheKey(serverId: string, cwd: string | null): string { - return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, cwd])}`; +function cacheKey(serverId: string, scope: ProviderSnapshotCacheScope): string { + return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, scope])}`; } function storedBytes(key: string, value: string): number { @@ -160,13 +164,13 @@ export function createProviderSnapshotCache( async function writeSnapshot(input: { serverId: string; - cwd: string | null; + scope: ProviderSnapshotCacheScope; hash: string; generatedAt: string; compactSnapshot: CompactProviderSnapshot; }): Promise { const currentIndex = await ensureCacheIndex(); - const key = cacheKey(input.serverId, input.cwd); + const key = cacheKey(input.serverId, input.scope); const stored = StoredProviderSnapshotSchema.safeParse({ version: CACHE_VERSION, hash: input.hash, @@ -206,11 +210,11 @@ export function createProviderSnapshotCache( } return { - async read(serverId, cwd) { + async read(serverId, scope) { try { return await runSerialized(async () => { const currentIndex = await ensureCacheIndex(); - const key = cacheKey(serverId, cwd); + const key = cacheKey(serverId, scope); const value = await storage.getItem(key); if (value === null) return null; try { diff --git a/packages/app/src/data/providers-snapshot.ts b/packages/app/src/data/providers-snapshot.ts index 5a59be037c..3f1c6e94d6 100644 --- a/packages/app/src/data/providers-snapshot.ts +++ b/packages/app/src/data/providers-snapshot.ts @@ -11,8 +11,15 @@ export function providersSnapshotQueryRoot(serverId: string | null) { return [PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId] as const; } -export function providersSnapshotQueryKey(serverId: string | null, cwd?: string | null) { +export function providersSnapshotQueryKey( + serverId: string | null, + cwd?: string | null, + workspaceId?: string | null, +) { const normalizedCwd = normalizeProvidersSnapshotCwd(cwd); + if (workspaceId) { + return [PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId, "workspace", workspaceId] as const; + } return normalizedCwd ? ([PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId, "cwd", normalizedCwd] as const) : ([PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId, "home"] as const); @@ -22,10 +29,12 @@ export function providersSnapshotRequestOptions(input: { cwd?: string | null; providers?: AgentProvider[]; ifNoneMatch?: string; + workspaceId?: string | null; }) { const normalizedCwd = normalizeProvidersSnapshotCwd(input.cwd); return { ...(normalizedCwd ? { cwd: normalizedCwd } : {}), + ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), ...(input.providers ? { providers: input.providers } : {}), ...(input.ifNoneMatch ? { ifNoneMatch: input.ifNoneMatch } : {}), }; diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index f468a3f928..1e5eedd263 100644 --- a/packages/app/src/data/push-router.test.ts +++ b/packages/app/src/data/push-router.test.ts @@ -12,6 +12,7 @@ import { mountServerDataPushRouter, workspaceTerminalsPushRoute, } from "@/data/push-router"; +import type { WorkspaceGitClient } from "@/git/workspace-git"; type ProvidersSnapshotUpdateMessage = Extract< SessionOutboundMessage, @@ -56,6 +57,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} unsubscribeCheckoutDiffCalls: string[]; subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>; unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>; + workspaceGit: (target: { workspaceId: string; cwd: string }) => WorkspaceGitClient; } { const handlers: Record = { providers_snapshot_update: [], @@ -94,29 +96,35 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} } } - return { - client: { - on, - async subscribeCheckoutDiff(cwd, compare, requestOptions) { - subscribeCheckoutDiffCalls.push({ - cwd, - compare, - subscriptionId: requestOptions.subscriptionId, - }); - if (config.rejectCheckoutDiffSubscribe) { - throw new Error("subscribe failed"); - } + function workspaceGit(target: { workspaceId: string; cwd: string }): WorkspaceGitClient { + return { + ...target, + queryIdentity: ["selected", target.workspaceId], + async subscribeDiff( + compare: Parameters[0], + requestOptions: Parameters[1], + ) { + const subscriptionId = requestOptions?.subscriptionId ?? "subscription"; + subscribeCheckoutDiffCalls.push({ cwd: target.cwd, compare, subscriptionId }); + if (config.rejectCheckoutDiffSubscribe) throw new Error("subscribe failed"); return { - subscriptionId: requestOptions.subscriptionId, - cwd, + workspaceId: target.workspaceId, + subscriptionId, + cwd: target.cwd, files: [], error: null, - requestId: requestOptions.requestId ?? "subscribe-checkout-diff", + requestId: requestOptions?.requestId ?? "subscribe-checkout-diff", }; }, - unsubscribeCheckoutDiff(subscriptionId) { + unsubscribeDiff(subscriptionId: string) { unsubscribeCheckoutDiffCalls.push(subscriptionId); }, + } as unknown as WorkspaceGitClient; + } + + return { + client: { + on, subscribeTerminals(subscription) { subscribeTerminalCalls.push(subscription); }, @@ -129,6 +137,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} unsubscribeCheckoutDiffCalls, subscribeTerminalCalls, unsubscribeTerminalCalls, + workspaceGit, }; } @@ -180,7 +189,8 @@ describe("server data push router", () => { const fake = createFakeClient(); const serverId = "server-1"; const cwd = "/repo"; - const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true); + const target = fake.workspaceGit({ workspaceId: "workspace-a", cwd }); + const queryKey = checkoutDiffQueryKey(serverId, target, "base", "main", true); const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`; const observer = new QueryObserver(queryClient, { queryKey, @@ -192,7 +202,7 @@ describe("server data push router", () => { enabled: true, serverId, subscriptionId, - cwd, + workspaceGit: target, compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, }), }); @@ -252,7 +262,8 @@ describe("server data push router", () => { const fake = createFakeClient({ rejectCheckoutDiffSubscribe: true }); const serverId = "server-1"; const cwd = "/repo"; - const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true); + const target = fake.workspaceGit({ workspaceId: "workspace-a", cwd }); + const queryKey = checkoutDiffQueryKey(serverId, target, "base", "main", true); const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`; const observer = new QueryObserver(queryClient, { queryKey, @@ -264,7 +275,7 @@ describe("server data push router", () => { enabled: true, serverId, subscriptionId, - cwd, + workspaceGit: target, compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, }), }); @@ -339,7 +350,8 @@ describe("server data push router", () => { const serverId = "server-1"; const cwd = "/repo"; const workspaceId = "workspace-a"; - const checkoutDiffKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true); + const target = fake.workspaceGit({ workspaceId, cwd }); + const checkoutDiffKey = checkoutDiffQueryKey(serverId, target, "base", "main", true); const checkoutDiffSubscriptionId = `checkoutDiff:${JSON.stringify(checkoutDiffKey)}`; const terminalKey = buildTerminalsQueryKey(serverId, cwd, workspaceId); const checkoutDiffObserver = new QueryObserver(queryClient, { @@ -352,7 +364,7 @@ describe("server data push router", () => { enabled: true, serverId, subscriptionId: checkoutDiffSubscriptionId, - cwd, + workspaceGit: target, compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, }), }); @@ -508,7 +520,17 @@ describe("server data push router", () => { const providerKey = providersSnapshotQueryKey(serverId); const daemonConfigKey = daemonConfigQueryKey(serverId); const pairingOfferKey = daemonPairingOfferQueryKey(serverId); - const diffKey = checkoutDiffQueryKey(serverId, "/repo", "uncommitted", undefined, false); + const diffKey = checkoutDiffQueryKey( + serverId, + { + workspaceId: "workspace-a", + cwd: "/repo", + queryIdentity: ["selected", "workspace-a"], + }, + "uncommitted", + undefined, + false, + ); const terminalKey = buildTerminalsQueryKey(serverId, "/repo", "workspace-a"); const otherProviderKey = providersSnapshotQueryKey(otherServerId); diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index 0af2e8e684..69f527a1dd 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -14,6 +14,8 @@ import { providersSnapshotQueryKey, providersSnapshotQueryRoot, } from "@/data/providers-snapshot"; +import type { ProviderSnapshotCacheScope } from "@/data/provider-snapshot-cache"; +import type { WorkspaceGitClient } from "@/git/workspace-git"; type ProvidersSnapshotUpdateMessage = Extract< SessionOutboundMessage, @@ -47,7 +49,7 @@ interface CheckoutDiffRoute { enabled: boolean; serverId: string; subscriptionId: string; - cwd: string; + workspaceGit: WorkspaceGitClient; compare: CheckoutDiffCompare; } @@ -72,12 +74,6 @@ interface ServerDataPushClient { type: TType, handler: (message: Extract) => void, ): () => void; - subscribeCheckoutDiff( - cwd: string, - compare: CheckoutDiffCompare, - options: { subscriptionId: string; requestId?: string }, - ): Promise; - unsubscribeCheckoutDiff(subscriptionId: string): void; subscribeTerminals(input: { cwd: string; workspaceId?: string }): void; unsubscribeTerminals(input: { cwd: string; workspaceId?: string }): void; } @@ -140,7 +136,7 @@ export function checkoutDiffPushRoute(input: { enabled: boolean; serverId: string; subscriptionId: string; - cwd: string; + workspaceGit: WorkspaceGitClient; compare: CheckoutDiffCompare; }): ServerDataQueryMeta { return { @@ -149,7 +145,7 @@ export function checkoutDiffPushRoute(input: { enabled: input.enabled, serverId: input.serverId, subscriptionId: input.subscriptionId, - cwd: input.cwd, + workspaceGit: input.workspaceGit, compare: input.compare, }, }; @@ -194,7 +190,11 @@ export function applyProvidersSnapshotUpdate(input: { if (input.message.type !== "providers_snapshot_update") { return; } - const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd); + const queryKey = providersSnapshotQueryKey( + input.serverId, + input.message.payload.cwd, + input.message.payload.workspaceId, + ); input.queryClient.setQueryData(queryKey, { entries: input.message.payload.entries, generatedAt: input.message.payload.generatedAt, @@ -204,7 +204,12 @@ export function applyProvidersSnapshotUpdate(input: { if (compactSnapshot && snapshotHash) { void (input.cache ?? providerSnapshotCache).write({ serverId: input.serverId, - cwd: normalizeProvidersSnapshotCwd(input.message.payload.cwd), + scope: input.message.payload.workspaceId + ? { type: "workspace", workspaceId: input.message.payload.workspaceId } + : ({ + type: "legacy-cwd", + cwd: normalizeProvidersSnapshotCwd(input.message.payload.cwd), + } satisfies ProviderSnapshotCacheScope), hash: snapshotHash, generatedAt: input.message.payload.generatedAt, compactSnapshot, @@ -341,8 +346,8 @@ export function mountServerDataPushRouter(input: PushRouterInput): () => void { unsubscribeCheckoutDiffUpdate(); unsubscribeCheckoutDiffResponse(); unsubscribeTerminalsChanged(); - for (const subscriptionId of activeCheckoutDiffSubscriptions.keys()) { - unsubscribeCheckoutDiff(input.client, subscriptionId); + for (const route of activeCheckoutDiffSubscriptions.values()) { + unsubscribeCheckoutDiff(route); } activeCheckoutDiffSubscriptions.clear(); for (const route of activeTerminalSubscriptions.values()) { @@ -363,7 +368,7 @@ function reconcileCheckoutDiffSubscriptions(input: { if (desired && areCheckoutDiffRoutesEqual(current, desired)) { continue; } - unsubscribeCheckoutDiff(input.client, subscriptionId); + unsubscribeCheckoutDiff(current); input.active.delete(subscriptionId); } @@ -372,8 +377,8 @@ function reconcileCheckoutDiffSubscriptions(input: { continue; } input.active.set(subscriptionId, desired); - void input.client - .subscribeCheckoutDiff(desired.cwd, desired.compare, { + void desired.workspaceGit + .subscribeDiff(desired.compare, { subscriptionId, requestId: `push-router:${input.serverId}:${subscriptionId}`, }) @@ -383,7 +388,7 @@ function reconcileCheckoutDiffSubscriptions(input: { } console.error("[server-data] subscribeCheckoutDiff failed", { serverId: input.serverId, - cwd: desired.cwd, + cwd: desired.workspaceGit.cwd, error, }); }); @@ -652,15 +657,15 @@ function readServerDataRoute(value: Record): ServerDataRoute | const domain = value.domain; const enabled = value.enabled; const serverId = value.serverId; - const cwd = value.cwd; - if (typeof enabled !== "boolean" || typeof serverId !== "string" || typeof cwd !== "string") { + if (typeof enabled !== "boolean" || typeof serverId !== "string") { return null; } if (domain === "checkoutDiff") { const subscriptionId = value.subscriptionId; const compare = readCheckoutDiffCompare(value.compare); - if (typeof subscriptionId !== "string" || !compare) { + const workspaceGit = readWorkspaceGitClient(value.workspaceGit); + if (typeof subscriptionId !== "string" || !compare || !workspaceGit) { return null; } return { @@ -668,12 +673,14 @@ function readServerDataRoute(value: Record): ServerDataRoute | enabled, serverId, subscriptionId, - cwd, + workspaceGit, compare, }; } if (domain === "workspaceTerminals") { + const cwd = value.cwd; + if (typeof cwd !== "string") return null; const workspaceId = value.workspaceId; if (workspaceId !== undefined && typeof workspaceId !== "string") { return null; @@ -720,7 +727,7 @@ function areCheckoutDiffRoutesEqual( return ( left?.serverId === right.serverId && left.subscriptionId === right.subscriptionId && - left.cwd === right.cwd && + left.workspaceGit === right.workspaceGit && left.compare.mode === right.compare.mode && left.compare.baseRef === right.compare.baseRef && left.compare.ignoreWhitespace === right.compare.ignoreWhitespace @@ -731,10 +738,13 @@ function isCheckoutDiffQueryKeyForRoute(queryKey: QueryKey, route: CheckoutDiffR return ( queryKey[0] === "checkoutDiff" && queryKey[1] === route.serverId && - queryKey[2] === route.cwd && - queryKey[3] === route.compare.mode && - queryKey[4] === (route.compare.baseRef ?? "") && - queryKey[5] === (route.compare.ignoreWhitespace === true) + Array.isArray(queryKey[2]) && + queryKey[2][0] === route.workspaceGit.queryIdentity[0] && + queryKey[2][1] === route.workspaceGit.queryIdentity[1] && + queryKey[3] === route.workspaceGit.cwd && + queryKey[4] === route.compare.mode && + queryKey[5] === (route.compare.baseRef ?? "") && + queryKey[6] === (route.compare.ignoreWhitespace === true) ); } @@ -763,14 +773,25 @@ function workspaceTerminalSubscriptionInput(route: WorkspaceTerminalsRoute): { }; } -function unsubscribeCheckoutDiff(client: ServerDataPushClient, subscriptionId: string): void { +function unsubscribeCheckoutDiff(route: CheckoutDiffRoute): void { try { - client.unsubscribeCheckoutDiff(subscriptionId); + route.workspaceGit.unsubscribeDiff(route.subscriptionId); } catch { // Disconnect cleanup can race with explicit subscription teardown. } } +function readWorkspaceGitClient(value: unknown): WorkspaceGitClient | null { + if (!isRecord(value)) return null; + return typeof value.workspaceId === "string" && + value.workspaceId.length > 0 && + typeof value.cwd === "string" && + typeof value.subscribeDiff === "function" && + typeof value.unsubscribeDiff === "function" + ? (value as unknown as WorkspaceGitClient) + : null; +} + function isQueryForServer(queryKey: QueryKey, kind: string, serverId: string): boolean { return queryKey.length >= 2 && queryKey[0] === kind && queryKey[1] === serverId; } diff --git a/packages/app/src/file-pane/live-file/hook.ts b/packages/app/src/file-pane/live-file/hook.ts index e318f7c0c6..f97275a9bc 100644 --- a/packages/app/src/file-pane/live-file/hook.ts +++ b/packages/app/src/file-pane/live-file/hook.ts @@ -4,6 +4,7 @@ import { LiveFileModel, type LiveFileSession } from "./model"; export function useLiveFile(input: { client: DaemonClient | null; + workspaceId: string; cwd: string | null; path: string | null; enabled: boolean; @@ -15,13 +16,13 @@ export function useLiveFile(input: { const client = input.client; return { subscribe(target, onVersion) { - return client.subscribeFile(target, onVersion); + return client.subscribeFile({ ...target, workspaceId: input.workspaceId }, onVersion); }, read(target) { - return client.readFile(target.cwd, target.path); + return client.readFile(target.cwd, target.path, undefined, input.workspaceId); }, }; - }, [input.client]); + }, [input.client, input.workspaceId]); useEffect(() => { if (!input.enabled || !session || !input.cwd || !input.path) { diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index c412504e0c..28c1f1478e 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -399,11 +399,13 @@ function FilePreviewBody({ export function FilePane({ serverId, + workspaceId, workspaceRoot, location, navigationRevision, }: { serverId: string; + workspaceId: string; workspaceRoot: string; location: WorkspaceFileLocation; navigationRevision: number; @@ -447,6 +449,7 @@ export function FilePane({ }); const liveFile = useLiveFile({ client, + workspaceId, cwd: readTarget?.cwd ?? null, path: readTarget?.path ?? null, enabled, @@ -487,6 +490,7 @@ export function FilePane({ ({ write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) { - return client.writeFile({ cwd, path, ...input }); + return client.writeFile({ cwd, path, workspaceId, ...input }); }, }), - [client, cwd, path], + [client, cwd, path, workspaceId], ); const [model] = useState(() => { return new FileEditorModel({ diff --git a/packages/app/src/git/actions-store.test.ts b/packages/app/src/git/actions-store.test.ts index 9ab27ba40f..4d7892450d 100644 --- a/packages/app/src/git/actions-store.test.ts +++ b/packages/app/src/git/actions-store.test.ts @@ -1,34 +1,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { queryClient as appQueryClient } from "@/data/query-client"; import { useSessionStore } from "@/stores/session-store"; import { __resetCheckoutGitActionsStoreForTests, useCheckoutGitActionsStore, } from "@/git/actions-store"; +import type { WorkspaceGitClient } from "@/git/workspace-git"; vi.mock("@react-native-async-storage/async-storage", () => ({ - default: { - getItem: vi.fn(async () => null), - setItem: vi.fn(async () => undefined), - removeItem: vi.fn(async () => undefined), - }, + default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() }, })); -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; +const serverId = "server-1"; +function capability(methods: Record): WorkspaceGitClient { + return { + workspaceId: "workspace-1", + cwd: "/tmp/repo/worktrees/feature", + ...methods, + } as unknown as WorkspaceGitClient; } describe("checkout-git-actions-store", () => { - const serverId = "server-1"; - const cwd = "/tmp/repo/worktrees/feature"; - beforeEach(() => { vi.useFakeTimers(); __resetCheckoutGitActionsStoreForTests(); @@ -40,267 +32,101 @@ describe("checkout-git-actions-store", () => { vi.useRealTimers(); __resetCheckoutGitActionsStoreForTests(); appQueryClient.clear(); - useSessionStore.setState((state) => ({ ...state, sessions: {} })); }); - it("shares pending state per checkout and de-dupes in-flight calls", async () => { - const deferred = createDeferred(); - const client = { - checkoutCommit: vi.fn(() => deferred.promise), - }; - - useSessionStore.setState((state) => ({ - ...state, - sessions: { - ...state.sessions, - [serverId]: { client } as unknown as (typeof state.sessions)[string], - }, - })); - + it("binds once to workspace identity and de-duplicates an in-flight commit", async () => { + let resolve!: (value: { error: null }) => void; + const commit = vi.fn(() => new Promise<{ error: null }>((done) => (resolve = done))); + const target = capability({ commit }); const store = useCheckoutGitActionsStore.getState(); - const first = store.commit({ serverId, cwd }); - const second = store.commit({ serverId, cwd }); - - expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("pending"); - - deferred.resolve({}); + const first = store.commit({ serverId, target }); + const second = store.commit({ serverId, target }); + expect(store.getStatus({ serverId, target, actionId: "commit" })).toBe("pending"); + resolve({ error: null }); await Promise.all([first, second]); - expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("success"); - - vi.advanceTimersByTime(1000); - expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("idle"); + expect(commit).toHaveBeenCalledTimes(1); + expect(store.getStatus({ serverId, target, actionId: "commit" })).toBe("success"); }); - it("runs pull then push sequentially for pull-and-push", async () => { + it("runs pull then push sequentially through the same bound capability", async () => { const order: string[] = []; - const client = { - checkoutPull: vi.fn(async () => { + const target = capability({ + pull: vi.fn(async () => { order.push("pull"); - return {}; + return { error: null }; }), - checkoutPush: vi.fn(async () => { + push: vi.fn(async () => { order.push("push"); - return {}; + return { error: null }; }), - }; - useSessionStore.setState((state) => ({ - ...state, - sessions: { - ...state.sessions, - [serverId]: { client } as unknown as (typeof state.sessions)[string], - }, - })); - - await useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }); - + }); + await useCheckoutGitActionsStore.getState().pullAndPush({ serverId, target }); expect(order).toEqual(["pull", "push"]); - expect( - useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }), - ).toBe("success"); }); - it("does not push when pull fails for pull-and-push", async () => { - const client = { - checkoutPull: vi.fn(async () => ({ error: { message: "pull conflict" } })), - checkoutPush: vi.fn(async () => ({})), - }; - useSessionStore.setState((state) => ({ - ...state, - sessions: { - ...state.sessions, - [serverId]: { client } as unknown as (typeof state.sessions)[string], - }, - })); + it("does not push when pull fails", async () => { + const push = vi.fn(); + const target = capability({ + pull: vi.fn(async () => ({ error: { message: "pull conflict" } })), + push, + }); await expect( - useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }), + useCheckoutGitActionsStore.getState().pullAndPush({ serverId, target }), ).rejects.toThrow("pull conflict"); + expect(push).not.toHaveBeenCalled(); expect( - useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }), + useCheckoutGitActionsStore + .getState() + .getStatus({ serverId, target, actionId: "pull-and-push" }), ).toBe("idle"); }); - it("surfaces push errors from pull-and-push after a successful pull", async () => { - const client = { - checkoutPull: vi.fn(async () => ({})), - checkoutPush: vi.fn(async () => ({ error: { message: "push rejected" } })), - }; - useSessionStore.setState((state) => ({ - ...state, - sessions: { - ...state.sessions, - [serverId]: { client } as unknown as (typeof state.sessions)[string], - }, - })); - + it("returns to idle when a bound mutation reports an error", async () => { + const target = capability({ + refresh: vi.fn(async () => ({ error: { message: "not git" } })), + }); await expect( - useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }), - ).rejects.toThrow("push rejected"); + useCheckoutGitActionsStore.getState().refresh({ serverId, target }), + ).rejects.toThrow("not git"); expect( - useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }), + useCheckoutGitActionsStore.getState().getStatus({ serverId, target, actionId: "refresh" }), ).toBe("idle"); }); - it("refreshes git and GitHub state and reports success", async () => { - const client = { - checkoutRefresh: vi.fn(async () => ({ success: true, error: null })), - }; - useSessionStore.setState((state) => ({ - ...state, - sessions: { - ...state.sessions, - [serverId]: { client } as unknown as (typeof state.sessions)[string], - }, - })); - - await useCheckoutGitActionsStore.getState().refresh({ serverId, cwd }); - - expect(client.checkoutRefresh).toHaveBeenCalledWith(cwd); - expect( - useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "refresh" }), - ).toBe("success"); - }); - - it("surfaces a refresh error and returns to idle", async () => { - const client = { - checkoutRefresh: vi.fn(async () => ({ error: { message: "not a git repository" } })), - }; - useSessionStore.setState((state) => ({ - ...state, - sessions: { - ...state.sessions, - [serverId]: { client } as unknown as (typeof state.sessions)[string], - }, - })); - - await expect(useCheckoutGitActionsStore.getState().refresh({ serverId, cwd })).rejects.toThrow( - "not a git repository", - ); - expect( - useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "refresh" }), - ).toBe("idle"); - }); - - it("discards selected paths through the shared checkout action workflow", async () => { - const checkoutDiscardChanges = vi.fn(async () => ({ success: true, error: null })); - const client = { checkoutDiscardChanges }; - useSessionStore.setState((state) => ({ - ...state, - sessions: { - ...state.sessions, - [serverId]: { client } as unknown as (typeof state.sessions)[string], - }, - })); + it("discards selected paths through the bound workspace capability", async () => { + const discardChanges = vi.fn(async () => ({ success: true, error: null })); + const target = capability({ discardChanges }); await useCheckoutGitActionsStore .getState() - .discardChanges({ serverId, cwd, paths: ["renamed.ts", "original.ts"] }); + .discardChanges({ serverId, target, paths: ["renamed.ts", "original.ts"] }); - expect(checkoutDiscardChanges).toHaveBeenCalledWith(cwd, { - paths: ["renamed.ts", "original.ts"], - }); + expect(discardChanges).toHaveBeenCalledWith({ paths: ["renamed.ts", "original.ts"] }); expect( useCheckoutGitActionsStore .getState() - .getStatus({ serverId, cwd, actionId: "discard-changes" }), + .getStatus({ serverId, target, actionId: "discard-changes" }), ).toBe("success"); }); - for (const rpc of [ - { - label: "forge", - method: "checkoutForgeSetAutoMerge", - feature: "checkoutForgeSetAutoMerge", - }, - { - label: "legacy GitHub", - method: "checkoutGithubSetAutoMerge", - feature: "checkoutGithubSetAutoMerge", - }, - ] as const) { - it(`enables PR auto-merge through the ${rpc.label} RPC`, async () => { - const setAutoMerge = vi.fn(async () => ({ - enabled: true, - success: true, - error: null, - })); - const client = { [rpc.method]: setAutoMerge }; - useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); - useSessionStore.getState().updateSessionServerInfo(serverId, { - serverId, - hostname: null, - version: null, - features: { [rpc.feature]: true }, - }); - - await useCheckoutGitActionsStore - .getState() - .enablePrAutoMerge({ serverId, cwd, method: "squash" }); - - expect(setAutoMerge).toHaveBeenCalledWith(cwd, { - enabled: true, - method: "squash", - }); - expect( - useCheckoutGitActionsStore - .getState() - .getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }), - ).toBe("success"); - }); - - it(`disables PR auto-merge through the ${rpc.label} RPC`, async () => { - const setAutoMerge = vi.fn(async () => ({ - enabled: false, - success: true, - error: null, - })); - const client = { [rpc.method]: setAutoMerge }; - useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); - useSessionStore.getState().updateSessionServerInfo(serverId, { - serverId, - hostname: null, - version: null, - features: { [rpc.feature]: true }, - }); - - await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd }); - - expect(setAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false }); - expect( - useCheckoutGitActionsStore - .getState() - .getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }), - ).toBe("success"); - }); - } - - it("does not call PR auto-merge RPCs when the daemon lacks the feature flag", async () => { - const client = { - checkoutForgeSetAutoMerge: vi.fn(async () => ({ - enabled: true, - success: true, - error: null, - })), - }; - useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); + it("fails auto-merge before invoking Git when the daemon lacks the feature", async () => { + const setForgeAutoMerge = vi.fn(); + const target = capability({ setForgeAutoMerge }); + useSessionStore.getState().initializeSession(serverId, {} as never); useSessionStore.getState().updateSessionServerInfo(serverId, { serverId, hostname: null, version: null, features: {}, }); - await expect( - useCheckoutGitActionsStore.getState().enablePrAutoMerge({ serverId, cwd, method: "merge" }), - ).rejects.toThrow("Update the host to use auto-merge actions."); - - expect(client.checkoutForgeSetAutoMerge).not.toHaveBeenCalled(); - expect( useCheckoutGitActionsStore .getState() - .getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }), - ).toBe("idle"); + .enablePrAutoMerge({ serverId, target, method: "merge" }), + ).rejects.toThrow("Update the host"); + expect(setForgeAutoMerge).not.toHaveBeenCalled(); }); }); diff --git a/packages/app/src/git/actions-store.ts b/packages/app/src/git/actions-store.ts index 853cd2ff83..f2307098f5 100644 --- a/packages/app/src/git/actions-store.ts +++ b/packages/app/src/git/actions-store.ts @@ -3,7 +3,9 @@ import { create } from "zustand"; import { queryClient as appQueryClient } from "@/data/query-client"; import { useSessionStore } from "@/stores/session-store"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; -import { i18n } from "@/i18n/i18next"; +import type { WorkspaceGitClient } from "@/git/workspace-git"; + +type WorkspaceGitTarget = WorkspaceGitClient; const SUCCESS_DISPLAY_MS = 1000; @@ -30,17 +32,8 @@ export type CheckoutGitAsyncActionId = type CheckoutKey = string; type StatusMap = Partial>; -function checkoutKey(serverId: string, cwd: string): CheckoutKey { - return `${serverId}::${cwd}`; -} - -function resolveClient(serverId: string) { - const session = useSessionStore.getState().sessions[serverId]; - const client = session?.client ?? null; - if (!client) { - throw new Error(i18n.t("common.errors.daemonClientUnavailable")); - } - return client; +function checkoutKey(serverId: string, target: WorkspaceGitTarget): CheckoutKey { + return `${serverId}::${target.workspaceId}::${target.cwd}`; } type AutoMergeActionsRpc = "forge" | "github"; @@ -81,8 +74,8 @@ function setStatus( }); } -function invalidateCheckoutGitQueries(serverId: string, cwd: string) { - return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd }); +function invalidateCheckoutGitQueries(serverId: string, target: WorkspaceGitTarget) { + return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, target }); } const successTimers = new Map>(); @@ -97,44 +90,56 @@ interface CheckoutGitActionsStoreState { getStatus: (params: { serverId: string; - cwd: string; + target: WorkspaceGitTarget; actionId: CheckoutGitAsyncActionId; }) => CheckoutGitActionStatus; - commit: (params: { serverId: string; cwd: string }) => Promise; - pull: (params: { serverId: string; cwd: string }) => Promise; - push: (params: { serverId: string; cwd: string }) => Promise; - pullAndPush: (params: { serverId: string; cwd: string }) => Promise; - refresh: (params: { serverId: string; cwd: string }) => Promise; - createPr: (params: { serverId: string; cwd: string }) => Promise; + commit: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise; + pull: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise; + push: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise; + pullAndPush: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise; + refresh: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise; + createPr: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise; mergePr: (params: { serverId: string; - cwd: string; + target: WorkspaceGitTarget; method: CheckoutPrMergeMethod; }) => Promise; enablePrAutoMerge: (params: { serverId: string; - cwd: string; + target: WorkspaceGitTarget; method: CheckoutPrMergeMethod; }) => Promise; - disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise; - mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise; - mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise; - discardChanges: (params: { serverId: string; cwd: string; paths: string[] }) => Promise; + disablePrAutoMerge: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise; + mergeBranch: (params: { + serverId: string; + target: WorkspaceGitTarget; + baseRef: string; + }) => Promise; + mergeFromBase: (params: { + serverId: string; + target: WorkspaceGitTarget; + baseRef: string; + }) => Promise; + discardChanges: (params: { + serverId: string; + target: WorkspaceGitTarget; + paths: string[]; + }) => Promise; } async function runCheckoutAction({ serverId, - cwd, + target, actionId, run, }: { serverId: string; - cwd: string; + target: WorkspaceGitTarget; actionId: CheckoutGitAsyncActionId; run: () => Promise; }): Promise { - const key = checkoutKey(serverId, cwd); + const key = checkoutKey(serverId, target); const inflightId = inFlightKey(key, actionId); const existing = inFlight.get(inflightId); @@ -154,7 +159,7 @@ async function runCheckoutAction({ const promise = (async () => { try { await run(); - await invalidateCheckoutGitQueries(serverId, cwd); + await invalidateCheckoutGitQueries(serverId, target); setStatus(key, actionId, "success"); const timer = setTimeout(() => { setStatus(key, actionId, "idle"); @@ -176,19 +181,18 @@ async function runCheckoutAction({ export const useCheckoutGitActionsStore = create()((set, get) => ({ statusByCheckout: {}, - getStatus: ({ serverId, cwd, actionId }) => { - const key = checkoutKey(serverId, cwd); + getStatus: ({ serverId, target, actionId }) => { + const key = checkoutKey(serverId, target); return get().statusByCheckout[key]?.[actionId] ?? "idle"; }, - commit: async ({ serverId, cwd }) => { + commit: async ({ serverId, target }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: "commit", run: async () => { - const client = resolveClient(serverId); - const payload = await client.checkoutCommit(cwd, { addAll: true }); + const payload = await target.commit({ addAll: true }); if (payload.error) { throw new Error(payload.error.message); } @@ -196,14 +200,13 @@ export const useCheckoutGitActionsStore = create() }); }, - pull: async ({ serverId, cwd }) => { + pull: async ({ serverId, target }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: "pull", run: async () => { - const client = resolveClient(serverId); - const payload = await client.checkoutPull(cwd); + const payload = await target.pull(); if (payload.error) { throw new Error(payload.error.message); } @@ -211,14 +214,13 @@ export const useCheckoutGitActionsStore = create() }); }, - push: async ({ serverId, cwd }) => { + push: async ({ serverId, target }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: "push", run: async () => { - const client = resolveClient(serverId); - const payload = await client.checkoutPush(cwd); + const payload = await target.push(); if (payload.error) { throw new Error(payload.error.message); } @@ -226,14 +228,13 @@ export const useCheckoutGitActionsStore = create() }); }, - refresh: async ({ serverId, cwd }) => { + refresh: async ({ serverId, target }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: "refresh", run: async () => { - const client = resolveClient(serverId); - const payload = await client.checkoutRefresh(cwd); + const payload = await target.refresh(); if (payload.error) { throw new Error(payload.error.message); } @@ -241,18 +242,17 @@ export const useCheckoutGitActionsStore = create() }); }, - pullAndPush: async ({ serverId, cwd }) => { + pullAndPush: async ({ serverId, target }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: "pull-and-push", run: async () => { - const client = resolveClient(serverId); - const pullPayload = await client.checkoutPull(cwd); + const pullPayload = await target.pull(); if (pullPayload.error) { throw new Error(pullPayload.error.message); } - const pushPayload = await client.checkoutPush(cwd); + const pushPayload = await target.push(); if (pushPayload.error) { throw new Error(pushPayload.error.message); } @@ -260,14 +260,13 @@ export const useCheckoutGitActionsStore = create() }); }, - createPr: async ({ serverId, cwd }) => { + createPr: async ({ serverId, target }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: "create-pr", run: async () => { - const client = resolveClient(serverId); - const payload = await client.checkoutPrCreate(cwd, {}); + const payload = await target.createPr({}); if (payload.error) { throw new Error(payload.error.message); } @@ -275,14 +274,13 @@ export const useCheckoutGitActionsStore = create() }); }, - mergePr: async ({ serverId, cwd, method }) => { + mergePr: async ({ serverId, target, method }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: `merge-pr-${method}`, run: async () => { - const client = resolveClient(serverId); - const payload = await client.checkoutPrMerge(cwd, { method }); + const payload = await target.mergePr(method); if (payload.error) { throw new Error(payload.error.message); } @@ -290,20 +288,19 @@ export const useCheckoutGitActionsStore = create() }); }, - enablePrAutoMerge: async ({ serverId, cwd, method }) => { + enablePrAutoMerge: async ({ serverId, target, method }) => { const rpc = resolveAutoMergeActionsRpc(serverId); await runCheckoutAction({ serverId, - cwd, + target, actionId: `enable-pr-auto-merge-${method}`, run: async () => { - const client = resolveClient(serverId); // COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once // all supported clients use checkout.forge.set_auto_merge.*. const payload = rpc === "forge" - ? await client.checkoutForgeSetAutoMerge(cwd, { enabled: true, method }) - : await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method }); + ? await target.setForgeAutoMerge({ enabled: true, method }) + : await target.setGithubAutoMerge({ enabled: true, method }); if (payload.error) { throw new Error(payload.error.message); } @@ -311,20 +308,19 @@ export const useCheckoutGitActionsStore = create() }); }, - disablePrAutoMerge: async ({ serverId, cwd }) => { + disablePrAutoMerge: async ({ serverId, target }) => { const rpc = resolveAutoMergeActionsRpc(serverId); await runCheckoutAction({ serverId, - cwd, + target, actionId: "disable-pr-auto-merge", run: async () => { - const client = resolveClient(serverId); // COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once // all supported clients use checkout.forge.set_auto_merge.*. const payload = rpc === "forge" - ? await client.checkoutForgeSetAutoMerge(cwd, { enabled: false }) - : await client.checkoutGithubSetAutoMerge(cwd, { enabled: false }); + ? await target.setForgeAutoMerge({ enabled: false }) + : await target.setGithubAutoMerge({ enabled: false }); if (payload.error) { throw new Error(payload.error.message); } @@ -332,14 +328,13 @@ export const useCheckoutGitActionsStore = create() }); }, - mergeBranch: async ({ serverId, cwd, baseRef }) => { + mergeBranch: async ({ serverId, target, baseRef }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: "merge-branch", run: async () => { - const client = resolveClient(serverId); - const payload = await client.checkoutMerge(cwd, { + const payload = await target.merge({ baseRef, strategy: "merge", requireCleanTarget: true, @@ -351,14 +346,13 @@ export const useCheckoutGitActionsStore = create() }); }, - mergeFromBase: async ({ serverId, cwd, baseRef }) => { + mergeFromBase: async ({ serverId, target, baseRef }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: "merge-from-base", run: async () => { - const client = resolveClient(serverId); - const payload = await client.checkoutMergeFromBase(cwd, { + const payload = await target.mergeFromBase({ baseRef, requireCleanTarget: true, }); @@ -369,18 +363,15 @@ export const useCheckoutGitActionsStore = create() }); }, - discardChanges: async ({ serverId, cwd, paths }) => { + discardChanges: async ({ serverId, target, paths }) => { await runCheckoutAction({ serverId, - cwd, + target, actionId: "discard-changes", run: async () => { - const client = resolveClient(serverId); - const payload = await client.checkoutDiscardChanges(cwd, { paths }); + const payload = await target.discardChanges({ paths }); if (!payload.success) { - throw new Error( - payload.error?.message ?? i18n.t("workspace.fileActions.confirmRevert.failed"), - ); + throw new Error(payload.error?.message ?? "Failed to discard changes"); } }, }); diff --git a/packages/app/src/git/branch-switcher-operations.test.ts b/packages/app/src/git/branch-switcher-operations.test.ts index 618550d625..86b039f061 100644 --- a/packages/app/src/git/branch-switcher-operations.test.ts +++ b/packages/app/src/git/branch-switcher-operations.test.ts @@ -1,54 +1,32 @@ import { describe, expect, it } from "vitest"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { WorkspaceGitClient } from "./workspace-git"; import { createBranchSwitcherOperations } from "./branch-switcher-operations"; function createRecordingClient() { - const cwds: string[] = []; + const calls: string[] = []; const client = { - getBranchSuggestions: async (options: { cwd: string; limit?: number }) => { - cwds.push(options.cwd); - return { branches: [], error: null }; - }, - stashList: async (cwd: string) => { - cwds.push(cwd); - return { entries: [] }; - }, - stashSave: async (cwd: string) => { - cwds.push(cwd); - return { error: null }; - }, - stashPop: async (cwd: string) => { - cwds.push(cwd); - return { error: null }; - }, - checkoutSwitchBranch: async (cwd: string) => { - cwds.push(cwd); - return { error: null }; - }, - } as unknown as DaemonClient; - return { client, cwds }; + workspaceId: "wks_3f9a2b1c", + cwd: "/Users/dev/project", + getBranchSuggestions: async () => (calls.push("suggest"), { branches: [], error: null }), + stashList: async () => (calls.push("list"), { entries: [] }), + stashSave: async () => (calls.push("save"), { error: null }), + stashPop: async () => (calls.push("pop"), { error: null }), + switchBranch: async () => (calls.push("switch"), { error: null }), + } as unknown as WorkspaceGitClient; + return { client, calls }; } describe("createBranchSwitcherOperations", () => { - it("sends the workspace directory as cwd to every git operation, never the workspace id", async () => { - const workspaceDirectory = "/Users/dev/project"; - const workspaceId = "wks_3f9a2b1c"; - const { client, cwds } = createRecordingClient(); + it("binds every operation to the selected workspace identity", async () => { + const { client, calls } = createRecordingClient(); - const operations = createBranchSwitcherOperations(client, workspaceDirectory); + const operations = createBranchSwitcherOperations(client); await operations.getBranchSuggestions(200); await operations.listPaseoStashes(); await operations.saveStash("main"); await operations.popStash(0); await operations.switchBranch("feature"); - expect(cwds).toEqual([ - workspaceDirectory, - workspaceDirectory, - workspaceDirectory, - workspaceDirectory, - workspaceDirectory, - ]); - expect(cwds).not.toContain(workspaceId); + expect(calls).toEqual(["suggest", "list", "save", "pop", "switch"]); }); }); diff --git a/packages/app/src/git/branch-switcher-operations.ts b/packages/app/src/git/branch-switcher-operations.ts index ad3ba21543..6daff00530 100644 --- a/packages/app/src/git/branch-switcher-operations.ts +++ b/packages/app/src/git/branch-switcher-operations.ts @@ -1,15 +1,15 @@ -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { WorkspaceGitClient } from "./workspace-git"; // Binds the branch switcher's git operations to a single workspace directory, so a // workspace id can never be passed where a cwd is expected. `cwd` is set once here; // callers choose the operation, never the directory. -export function createBranchSwitcherOperations(client: DaemonClient, cwd: string) { +export function createBranchSwitcherOperations(workspaceGit: WorkspaceGitClient) { return { - getBranchSuggestions: (limit: number) => client.getBranchSuggestions({ cwd, limit }), - listPaseoStashes: () => client.stashList(cwd, { paseoOnly: true }), - saveStash: (branch: string | undefined) => client.stashSave(cwd, { branch }), - popStash: (stashIndex: number) => client.stashPop(cwd, stashIndex), - switchBranch: (branch: string) => client.checkoutSwitchBranch(cwd, branch), + getBranchSuggestions: (limit: number) => workspaceGit.getBranchSuggestions({ limit }), + listPaseoStashes: () => workspaceGit.stashList({ paseoOnly: true }), + saveStash: (branch: string | undefined) => workspaceGit.stashSave({ branch }), + popStash: (stashIndex: number) => workspaceGit.stashPop(stashIndex), + switchBranch: (branch: string) => workspaceGit.switchBranch(branch), }; } diff --git a/packages/app/src/git/checkout-status-cache.test.ts b/packages/app/src/git/checkout-status-cache.test.ts index df8952749c..78980fe0d7 100644 --- a/packages/app/src/git/checkout-status-cache.test.ts +++ b/packages/app/src/git/checkout-status-cache.test.ts @@ -2,332 +2,129 @@ import { QueryClient } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages"; -import { - checkoutCommitsQueryKey, - checkoutPrStatusQueryKey, - checkoutStatusQueryKey, -} from "@/git/query-keys"; -import { - prPanePipelineQueryKey, - prPaneTimelineQueryKey, -} from "@/git/pull-request-panel/query-keys"; +import { checkoutCommitsQueryKey, checkoutStatusQueryKey } from "@/git/query-keys"; import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store"; import { applyCheckoutStatusUpdateFromEvent, ensureCheckoutStatus, - type CheckoutPrStatusPayload, - type CheckoutStatusPayload, fetchCheckoutStatus, + type CheckoutStatusPayload, } from "./checkout-status-cache"; vi.mock("@react-native-async-storage/async-storage", () => ({ - default: { - getItem: vi.fn(async () => null), - setItem: vi.fn(async () => undefined), - removeItem: vi.fn(async () => undefined), - }, + default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() }, })); const serverId = "server-1"; -const cwd = "/repo"; - -function checkoutStatus(overrides: Partial = {}): CheckoutStatusPayload { +const targetA = { + workspaceId: "workspace-a", + cwd: "/workspace", + queryIdentity: ["selected", "workspace-a"] as const, +}; +const targetB = { + workspaceId: "workspace-b", + cwd: "/workspace", + queryIdentity: ["selected", "workspace-b"] as const, +}; + +function status( + target: { workspaceId: string; cwd: string } = targetA, + branch = "main", + isDirty = false, +): CheckoutStatusPayload { return { - cwd, + workspaceId: target.workspaceId, + cwd: target.cwd, error: null, - requestId: "checkout-status-1", + requestId: `status-${target.workspaceId}`, isGit: true, isPaseoOwnedWorktree: false, - repoRoot: cwd, - currentBranch: "main", - isDirty: false, + repoRoot: target.cwd, + mainRepoRoot: null, + currentBranch: branch, + isDirty, baseRef: "origin/main", aheadBehind: { ahead: 0, behind: 0 }, aheadOfOrigin: 0, behindOfOrigin: 0, hasRemote: true, - remoteUrl: "git@github.com:getpaseo/paseo.git", - ...overrides, - } as CheckoutStatusPayload; -} - -function prStatus(overrides: Partial = {}): CheckoutPrStatusPayload { - return { - cwd, - status: { - forge: "github", - url: "https://github.com/getpaseo/paseo/pull/42", - title: "My PR", - state: "open", - baseRefName: "main", - headRefName: "feature", - isMerged: false, - isDraft: false, - mergeable: "MERGEABLE", - checks: [], - checksStatus: "success", - reviewDecision: null, - }, - githubFeaturesEnabled: true, - authState: "authenticated", - forge: "github", - error: null, - requestId: "pr-status-1", - ...overrides, - }; -} - -function checkoutStatusUpdate( - payload: CheckoutStatusPayload, - extraPrStatus?: NonNullable, -): CheckoutStatusUpdate { - return { - type: "checkout_status_update", - payload: extraPrStatus ? { ...payload, prStatus: extraPrStatus } : payload, + remoteUrl: null, }; } -function setDiffModeOverride(isDirtyAtSelection: boolean): void { - useReviewDraftStore.getState().setDiffModeOverride({ - scopeKey: "review:scope", - override: { serverId, cwd, mode: "base", isDirtyAtSelection }, - }); -} - -function createQueryClient(): QueryClient { - return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +function update(payload: CheckoutStatusPayload): CheckoutStatusUpdate { + return { type: "checkout_status_update", payload }; } -beforeEach(() => { - resetReviewDraftStore(); -}); - -describe("fetchCheckoutStatus", () => { - it("fetches from the client and returns the payload", async () => { - const fetched = checkoutStatus({ requestId: "fetch-1" }); - const client = { getCheckoutStatus: vi.fn(async () => fetched) }; +beforeEach(resetReviewDraftStore); - const result = await fetchCheckoutStatus({ client, serverId, cwd }); - - expect(result).toEqual(fetched); - expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd); +describe("selected checkout status cache", () => { + it("fetches through the bound workspace capability", async () => { + const getStatus = vi.fn(async () => status()); + await expect( + fetchCheckoutStatus({ client: { getStatus }, serverId, target: targetA }), + ).resolves.toEqual(status()); + expect(getStatus).toHaveBeenCalledWith(); }); - it("expires a manual diff-mode override when the fetched dirty state flipped", async () => { - setDiffModeOverride(true); - const client = { getCheckoutStatus: vi.fn(async () => checkoutStatus({ isDirty: false })) }; - - await fetchCheckoutStatus({ client, serverId, cwd }); - - expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined(); - }); -}); - -describe("ensureCheckoutStatus", () => { - it("awaits the canonical checkout-status query and reuses its cached result", async () => { - const queryClient = createQueryClient(); - const fetched = checkoutStatus({ currentBranch: "feature/current" }); - const client = { getCheckoutStatus: vi.fn(async () => fetched) }; - - const first = await ensureCheckoutStatus({ queryClient, client, serverId, cwd }); - const second = await ensureCheckoutStatus({ queryClient, client, serverId, cwd }); - - expect(first).toEqual(fetched); - expect(second).toEqual(fetched); - expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd); - }); - - it("awaits a refetch when the canonical cached status was invalidated", async () => { - const queryClient = createQueryClient(); - queryClient.setQueryData( - checkoutStatusQueryKey(serverId, cwd), - checkoutStatus({ currentBranch: "feature/stale" }), - ); - await queryClient.invalidateQueries({ - queryKey: checkoutStatusQueryKey(serverId, cwd), - refetchType: "none", - }); - const fetched = checkoutStatus({ currentBranch: "feature/current" }); - const client = { getCheckoutStatus: vi.fn(async () => fetched) }; - - const result = await ensureCheckoutStatus({ queryClient, client, serverId, cwd }); - - expect(result.currentBranch).toBe("feature/current"); - expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd); - }); -}); - -describe("applyCheckoutStatusUpdateFromEvent", () => { - it("writes the checkout status to the cache using the cwd from the payload", () => { - const queryClient = createQueryClient(); - const pushed = checkoutStatus({ requestId: "push-1", isDirty: true }); - - applyCheckoutStatusUpdateFromEvent({ - queryClient, + it("deduplicates a bound status fetch by workspace identity", async () => { + const client = new QueryClient(); + const getStatus = vi.fn(async () => status()); + const first = ensureCheckoutStatus({ + queryClient: client, + client: { getStatus }, serverId, - message: checkoutStatusUpdate(pushed), + target: targetA, }); - - expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed); - }); - - it("invalidates recent commits when checkout status is pushed", () => { - const queryClient = createQueryClient(); - queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] }); - queryClient.setQueryData(checkoutCommitsQueryKey(serverId, "/repo2"), { commits: [] }); - - applyCheckoutStatusUpdateFromEvent({ - queryClient, + const second = ensureCheckoutStatus({ + queryClient: client, + client: { getStatus }, serverId, - message: checkoutStatusUpdate(checkoutStatus()), + target: targetA, }); - - expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe( - true, - ); - expect( - queryClient.getQueryState(checkoutCommitsQueryKey(serverId, "/repo2"))?.isInvalidated, - ).toBe(false); + await Promise.all([first, second]); + expect(getStatus).toHaveBeenCalledTimes(1); }); - it("writes the PR status cache when prStatus is present, and skips it otherwise", () => { - const queryClient = createQueryClient(); - const pushedPr = prStatus({ requestId: "pr-1" }); + it("keeps same-cwd selected status and invalidation isolated", () => { + const client = new QueryClient(); + client.setQueryData(checkoutStatusQueryKey(serverId, targetA), status(targetA, "a")); + client.setQueryData(checkoutStatusQueryKey(serverId, targetB), status(targetB, "b")); + client.setQueryData(checkoutCommitsQueryKey(serverId, targetA), { commits: [] }); + client.setQueryData(checkoutCommitsQueryKey(serverId, targetB), { commits: [] }); applyCheckoutStatusUpdateFromEvent({ - queryClient, + queryClient: client, serverId, - message: checkoutStatusUpdate(checkoutStatus(), pushedPr), - }); - expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd))).toEqual(pushedPr); - - const otherCwd = "/repo2"; - applyCheckoutStatusUpdateFromEvent({ - queryClient, - serverId, - message: checkoutStatusUpdate(checkoutStatus({ cwd: otherCwd, repoRoot: otherCwd })), - }); - expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, otherCwd))).toBeUndefined(); - }); - - it("normalizes legacy PR auth state at the pushed-cache boundary", () => { - const queryClient = createQueryClient(); - const { authState: _authState, ...legacyPrStatus } = prStatus({ - githubFeaturesEnabled: false, - }); - - applyCheckoutStatusUpdateFromEvent({ - queryClient, - serverId, - message: checkoutStatusUpdate(checkoutStatus(), legacyPrStatus), + message: update(status(targetA, "a-next", true)), }); expect( - queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd)) - ?.authState, - ).toBe("unauthenticated"); + client.getQueryData(checkoutStatusQueryKey(serverId, targetA)) + ?.currentBranch, + ).toBe("a-next"); expect( - queryClient.getQueryData( - checkoutStatusQueryKey(serverId, cwd), - )?.prStatus?.authState, - ).toBe("unauthenticated"); - }); - - it("expires a manual diff-mode override when the pushed dirty state flipped", () => { - const queryClient = createQueryClient(); - setDiffModeOverride(false); - - applyCheckoutStatusUpdateFromEvent({ - queryClient, - serverId, - message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })), - }); - - expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined(); - }); - - it("keeps a manual diff-mode override while the pushed dirty state still matches", () => { - const queryClient = createQueryClient(); - setDiffModeOverride(true); - - applyCheckoutStatusUpdateFromEvent({ - queryClient, - serverId, - message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })), - }); - - expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeDefined(); - }); - - it("invalidates PR detail queries when the prStatus changes, ignoring the volatile requestId", () => { - const queryClient = createQueryClient(); - queryClient.setQueryData( - checkoutPrStatusQueryKey(serverId, cwd), - prStatus({ requestId: "pr-v1" }), + client.getQueryData(checkoutStatusQueryKey(serverId, targetB)) + ?.currentBranch, + ).toBe("b"); + expect(client.getQueryState(checkoutCommitsQueryKey(serverId, targetA))?.isInvalidated).toBe( + true, + ); + expect(client.getQueryState(checkoutCommitsQueryKey(serverId, targetB))?.isInvalidated).toBe( + false, ); - const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 }); - const pipelineKey = prPanePipelineQueryKey({ - serverId, - cwd, - pipelineId: 9001, - changeRequestNumber: 1, - }); - queryClient.setQueryData(timelineKey, { items: [] }); - queryClient.setQueryData(pipelineKey, { stages: [] }); - - applyCheckoutStatusUpdateFromEvent({ - queryClient, - serverId, - message: checkoutStatusUpdate(checkoutStatus(), prStatus({ requestId: "pr-v2" })), - }); - expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(false); - expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(false); - - applyCheckoutStatusUpdateFromEvent({ - queryClient, - serverId, - message: checkoutStatusUpdate( - checkoutStatus(), - prStatus({ - requestId: "pr-v3", - status: { ...prStatus().status!, state: "closed" }, - }), - ), - }); - expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(true); }); - it("invalidates PR detail queries on the first prStatus emission, scoped to its cwd", () => { - const queryClient = createQueryClient(); - const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 }); - const otherTimelineKey = prPaneTimelineQueryKey({ serverId, cwd: "/repo2", prNumber: 42 }); - const pipelineKey = prPanePipelineQueryKey({ - serverId, - cwd, - pipelineId: 9001, - changeRequestNumber: 1, + it("expires a diff-mode override when a bound update changes dirty state", () => { + useReviewDraftStore.getState().setDiffModeOverride({ + scopeKey: "review:scope", + override: { serverId, cwd: targetA.cwd, mode: "base", isDirtyAtSelection: false }, }); - const otherPipelineKey = prPanePipelineQueryKey({ - serverId, - cwd: "/repo2", - pipelineId: 9001, - changeRequestNumber: 1, - }); - queryClient.setQueryData(timelineKey, { items: [] }); - queryClient.setQueryData(otherTimelineKey, { items: [] }); - queryClient.setQueryData(pipelineKey, { stages: [] }); - queryClient.setQueryData(otherPipelineKey, { stages: [] }); - applyCheckoutStatusUpdateFromEvent({ - queryClient, + queryClient: new QueryClient(), serverId, - message: checkoutStatusUpdate(checkoutStatus(), prStatus()), + message: update(status(targetA, "main", true)), }); - - expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(otherTimelineKey)?.isInvalidated).toBe(false); - expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(true); - expect(queryClient.getQueryState(otherPipelineKey)?.isInvalidated).toBe(false); + expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined(); }); }); diff --git a/packages/app/src/git/checkout-status-cache.ts b/packages/app/src/git/checkout-status-cache.ts index 1f76dc67b1..ed831eb151 100644 --- a/packages/app/src/git/checkout-status-cache.ts +++ b/packages/app/src/git/checkout-status-cache.ts @@ -5,15 +5,21 @@ import { checkoutCommitsQueryKey, checkoutPrStatusQueryKey, checkoutStatusQueryKey, + legacyCheckoutStatusQueryKey, invalidatePrPaneTimelineForCheckout, } from "@/git/query-keys"; import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status"; import { expireStaleDiffModeOverrides } from "@/review/store"; +import type { WorkspaceGitClient, WorkspaceGitStatusTarget } from "./workspace-git"; export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; export type { CheckoutPrStatusPayload } from "@/git/pr-status"; export interface CheckoutStatusClient { + getStatus: WorkspaceGitClient["getStatus"]; +} + +export interface LegacyCheckoutStatusClient { getCheckoutStatus: (cwd: string) => Promise; } @@ -24,14 +30,18 @@ export interface CheckoutStatusClient { export async function fetchCheckoutStatus({ client, serverId, - cwd, + target, }: { client: CheckoutStatusClient; serverId: string; - cwd: string; + target: WorkspaceGitStatusTarget; }): Promise { - const payload = await client.getCheckoutStatus(cwd); - expireStaleDiffModeOverrides({ serverId, cwd, isDirty: payload.isGit && payload.isDirty }); + const payload = await client.getStatus(); + expireStaleDiffModeOverrides({ + serverId, + cwd: target.cwd, + isDirty: payload.isGit && payload.isDirty, + }); return payload; } @@ -39,16 +49,38 @@ export async function ensureCheckoutStatus({ queryClient, client, serverId, - cwd, + target, }: { queryClient: QueryClient; client: CheckoutStatusClient; serverId: string; + target: WorkspaceGitStatusTarget; +}): Promise { + return await queryClient.fetchQuery({ + queryKey: checkoutStatusQueryKey(serverId, target), + queryFn: () => fetchCheckoutStatus({ client, serverId, target }), + staleTime: Infinity, + }); +} + +export async function ensureLegacyCheckoutStatus({ + queryClient, + client, + serverId, + cwd, +}: { + queryClient: QueryClient; + client: LegacyCheckoutStatusClient; + serverId: string; cwd: string; }): Promise { return await queryClient.fetchQuery({ - queryKey: checkoutStatusQueryKey(serverId, cwd), - queryFn: () => fetchCheckoutStatus({ client, serverId, cwd }), + queryKey: legacyCheckoutStatusQueryKey(serverId, cwd), + queryFn: async () => { + const payload = await client.getCheckoutStatus(cwd); + expireStaleDiffModeOverrides({ serverId, cwd, isDirty: payload.isGit && payload.isDirty }); + return payload; + }, staleTime: Infinity, }); } @@ -67,9 +99,15 @@ export function applyCheckoutStatusUpdateFromEvent({ ? normalizeCheckoutPrStatusPayload(payload.prStatus) : undefined; const cachePayload = prStatus ? { ...payload, prStatus } : payload; - queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload); + if (payload.workspaceId === undefined) return; + const target = { + workspaceId: payload.workspaceId, + cwd: payload.cwd, + queryIdentity: ["selected", payload.workspaceId] as const, + }; + queryClient.setQueryData(checkoutStatusQueryKey(serverId, target), cachePayload); void queryClient.invalidateQueries({ - queryKey: checkoutCommitsQueryKey(serverId, payload.cwd), + queryKey: checkoutCommitsQueryKey(serverId, target), }); expireStaleDiffModeOverrides({ serverId, @@ -82,14 +120,14 @@ export function applyCheckoutStatusUpdateFromEvent({ } const previous = queryClient.getQueryData( - checkoutPrStatusQueryKey(serverId, prStatus.cwd), + checkoutPrStatusQueryKey(serverId, target), ); - queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, prStatus.cwd), prStatus); + queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, target), prStatus); // The PR activity timeline has no push channel; mark it stale when the pushed PR status // meaningfully changed. Active panes refetch immediately, evicted ones on next mount. if (hasPrStatusChanged(previous, prStatus)) { - void invalidatePrPaneTimelineForCheckout(queryClient, { serverId, cwd: prStatus.cwd }); + void invalidatePrPaneTimelineForCheckout(queryClient, { serverId, target }); } } diff --git a/packages/app/src/git/client-forge-module.ts b/packages/app/src/git/client-forge-module.ts index cbc71da571..8a003ff8df 100644 --- a/packages/app/src/git/client-forge-module.ts +++ b/packages/app/src/git/client-forge-module.ts @@ -66,6 +66,7 @@ export interface MergeCapability { export interface PaneChecksSlotContext { serverId: string; + workspaceId: string; cwd: string; /** Change request (PR/MR) number, so a section can address its head pipeline. */ changeRequestNumber: number; diff --git a/packages/app/src/git/commits-section/commits-section.tsx b/packages/app/src/git/commits-section/commits-section.tsx index bd95a3db47..f067663697 100644 --- a/packages/app/src/git/commits-section/commits-section.tsx +++ b/packages/app/src/git/commits-section/commits-section.tsx @@ -11,7 +11,6 @@ import { CommitRow } from "./commit-row"; interface CommitsSectionProps { serverId: string; - cwd: string; onCommitPress: (sha: string) => void; } @@ -82,7 +81,7 @@ function CommitsSectionContent({ ); } -export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionProps) { +export function CommitsSection({ serverId, onCommitPress }: CommitsSectionProps) { const { t } = useTranslation(); const { preferences, updatePreferences } = useChangesPreferences(); const isPanelActive = useRetainedPanelActive(); @@ -91,7 +90,6 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]); const query = useCheckoutCommitsQuery({ serverId, - cwd, enabled: !collapsed, }); diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 5c70f61dab..58d157bdc3 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -93,6 +93,7 @@ import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import type { ForgeAuthState } from "@getpaseo/protocol/messages"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; import { useToast } from "@/contexts/toast-context"; import { useSessionStore } from "@/stores/session-store"; import { confirmDialog } from "@/utils/confirm-dialog"; @@ -241,15 +242,14 @@ function noopStartComment(): void {} function useDiscardChangesAction({ serverId, - cwd, diffMode, }: { serverId: string; - cwd: string; diffMode: "uncommitted" | "base"; }): ((path: string, oldPath?: string) => void) | undefined { const { t } = useTranslation(); const toast = useToast(); + const workspaceGit = useRequiredWorkspaceGit(); const discardChanges = useCheckoutGitActionsStore((state) => state.discardChanges); // COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08. const discardSupported = useSessionStore( @@ -270,7 +270,7 @@ function useDiscardChangesAction({ try { await discardChanges({ serverId, - cwd, + target: workspaceGit, paths: oldPath ? [path, oldPath] : [path], }); } catch (cause) { @@ -279,7 +279,7 @@ function useDiscardChangesAction({ ); } }, - [cwd, discardChanges, serverId, t, toast], + [discardChanges, serverId, t, toast, workspaceGit], ); const handleDiscardPath = useCallback( (path: string, oldPath?: string) => { @@ -2848,6 +2848,7 @@ export function GitDiffPane({ onOpenFile, onAddToChat, }: GitDiffPaneProps) { + const workspaceGit = useRequiredWorkspaceGit(); const { settings: appSettings } = useAppSettings(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); @@ -2909,17 +2910,22 @@ export function GitDiffPane({ ); const runRefresh = useCheckoutGitActionsStore((s) => s.refresh); const isRefreshing = - useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) === - "pending"; + useCheckoutGitActionsStore((s) => + s.getStatus({ + serverId, + target: workspaceGit, + actionId: "refresh", + }), + ) === "pending"; const handleRefresh = useCallback(() => { if (isRefreshing) { return; } - void runRefresh({ serverId, cwd }).catch((error) => { + void runRefresh({ serverId, target: workspaceGit }).catch((error) => { toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); }); - }, [cwd, isRefreshing, runRefresh, serverId, t, toast]); + }, [isRefreshing, runRefresh, serverId, t, toast, workspaceGit]); const { status, @@ -2940,7 +2946,7 @@ export function GitDiffPane({ reviewAttachment, } = useWorkingDiff({ serverId, - workspaceId: workspaceId ?? undefined, + workspaceId: workspaceGit.workspaceId, cwd, ignoreWhitespace: changesPreferences.hideWhitespace, enabled: enabled !== false, @@ -2959,7 +2965,6 @@ export function GitDiffPane({ payloadError: prPayloadError, } = useCheckoutPrStatusQuery({ serverId, - cwd, enabled: isGit, }); const forgeProvidersSupported = useSessionStore( @@ -2987,7 +2992,7 @@ export function GitDiffPane({ [updateChangesPreferences], ); const changesTree = useChangesTreeState({ - workspaceId, + workspaceId: workspaceGit.workspaceId, cwd, files, viewMode, @@ -3056,7 +3061,7 @@ export function GitDiffPane({ }, [client, cwd, t, toast], ); - const onRevertPath = useDiscardChangesAction({ serverId, cwd, diffMode }); + const onRevertPath = useDiscardChangesAction({ serverId, diffMode }); const workingTreeMode = useMemo( () => ({ kind: "working_tree" as const, @@ -3161,8 +3166,6 @@ export function GitDiffPane({ @@ -3236,7 +3239,7 @@ export function GitDiffPane({ {bodyContent} - + ); } diff --git a/packages/app/src/git/forges/gitlab.view.tsx b/packages/app/src/git/forges/gitlab.view.tsx index d88ade1f95..2c0a5c84dd 100644 --- a/packages/app/src/git/forges/gitlab.view.tsx +++ b/packages/app/src/git/forges/gitlab.view.tsx @@ -54,7 +54,6 @@ function renderGitlabChecksSection(facts: GitlabMergeFacts, ctx: PaneChecksSlotC return ( { + const getForgeCheckDetails = vi.fn(async () => ({ + cwd: "/shared", + requestId: "check-details", + success: true, + details: { + checkRunId: 42, + name: "Runtime check", + output: { title: "Runtime check", summary: "failed", text: "runtime log attachment" }, + annotations: [], + failedJobs: [], + truncated: false, + }, + error: null, + })); + const workspaceGit = { + workspaceId: "workspace-selected", + cwd: "/shared", + getForgeCheckDetails, + getGithubCheckDetails: vi.fn(), + }; + + await expect( + fetchBoundPullRequestCheckDetails({ + workspaceGit, + transport: "forge", + repoOwner: "getpaseo", + repoName: "paseo", + checkRunId: 42, + changeRequestNumber: 99, + }), + ).resolves.toMatchObject({ output: { text: "runtime log attachment" } }); + expect(workspaceGit).toMatchObject({ workspaceId: "workspace-selected", cwd: "/shared" }); + expect(getForgeCheckDetails).toHaveBeenCalledWith({ + repoOwner: "getpaseo", + repoName: "paseo", + checkRunId: 42, + workflowRunId: undefined, + changeRequestNumber: 99, + }); +}); diff --git a/packages/app/src/git/pull-request-panel/check-details.ts b/packages/app/src/git/pull-request-panel/check-details.ts new file mode 100644 index 0000000000..4f5ec07569 --- /dev/null +++ b/packages/app/src/git/pull-request-panel/check-details.ts @@ -0,0 +1,32 @@ +import type { WorkspaceGitClient } from "@/git/workspace-git"; + +type CheckDetailsClient = Pick< + WorkspaceGitClient, + "workspaceId" | "cwd" | "getForgeCheckDetails" | "getGithubCheckDetails" +>; + +export async function fetchBoundPullRequestCheckDetails(input: { + workspaceGit: CheckDetailsClient; + transport: "forge" | "github"; + repoOwner: string; + repoName: string; + checkRunId?: number; + workflowRunId?: number; + changeRequestNumber: number; +}) { + const request = { + repoOwner: input.repoOwner, + repoName: input.repoName, + checkRunId: input.checkRunId, + workflowRunId: input.workflowRunId, + changeRequestNumber: input.changeRequestNumber, + }; + const payload = + input.transport === "forge" + ? await input.workspaceGit.getForgeCheckDetails(request) + : await input.workspaceGit.getGithubCheckDetails(request); + if (!payload.success) { + throw new Error(payload.error?.message ?? "Could not load check details"); + } + return payload.details ?? null; +} diff --git a/packages/app/src/git/pull-request-panel/pane.tsx b/packages/app/src/git/pull-request-panel/pane.tsx index 582d9543a2..6563e0b4d4 100644 --- a/packages/app/src/git/pull-request-panel/pane.tsx +++ b/packages/app/src/git/pull-request-panel/pane.tsx @@ -38,11 +38,11 @@ import { import { MarkdownRenderer } from "@/components/markdown/renderer"; import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment"; import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; -import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; import { useToast } from "@/contexts/toast-context"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; import { isNative } from "@/constants/platform"; import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { ICON_SIZE, type Theme } from "@/styles/theme"; @@ -67,6 +67,7 @@ import { canAddPullRequestCheckLogsToChat, } from "./context-attachment"; import { getActivityVerb, getStateLabel } from "./data"; +import { fetchBoundPullRequestCheckDetails } from "./check-details"; import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data"; import type { ForgeSpecificStatusFacts } from "@/git/merge-capability"; import { @@ -205,12 +206,14 @@ function removeLoadingCheck(current: ReadonlySet, checkKey: string): Rea export function PullRequestPane({ serverId, + workspaceId, cwd, data, activityLoading, workspaceAttachmentScopeKey, }: { serverId: string; + workspaceId: string; cwd: string; data: PrPaneData; activityLoading: boolean; @@ -218,7 +221,7 @@ export function PullRequestPane({ }) { const { t } = useTranslation(); const toast = useToast(); - const daemonClient = useHostRuntimeClient(serverId); + const workspaceGit = useRequiredWorkspaceGit(); // COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once // all supported clients use checkout.forge.get_check_details.*. const canFetchGitHubCheckDetails = useSessionStore( @@ -248,17 +251,17 @@ export function PullRequestPane({ const runRefresh = useCheckoutGitActionsStore((state) => state.refresh); const isRefreshing = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "refresh" }), + state.getStatus({ serverId, target: workspaceGit, actionId: "refresh" }), ) === "pending"; const handleRefresh = useCallback(() => { if (isRefreshing) { return; } - void runRefresh({ serverId, cwd }).catch((error) => { + void runRefresh({ serverId, target: workspaceGit }).catch((error) => { toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); }); - }, [cwd, isRefreshing, runRefresh, serverId, t, toast]); + }, [isRefreshing, runRefresh, serverId, t, toast, workspaceGit]); const handleToggleChecks = useCallback(() => { setChecksOpen((open) => !open); @@ -399,29 +402,21 @@ export function PullRequestPane({ canFetchForgeCheckDetails || (check.provider === "github" && canFetchGitHubCheckDetails); if ( canFetchDetail && - daemonClient && (ref?.checkRunId !== undefined || ref?.workflowRunId !== undefined) && data.repoOwner && data.repoName ) { - try { - const request = { - cwd, - repoOwner: data.repoOwner, - repoName: data.repoName, - checkRunId: ref.checkRunId, - workflowRunId: ref.workflowRunId, - changeRequestNumber: data.number, - }; - // COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once - // all supported clients use checkout.forge.get_check_details.*. - const payload = canFetchForgeCheckDetails - ? await daemonClient.checkoutForgeGetCheckDetails(request) - : await daemonClient.checkoutGithubGetCheckDetails(request); - details = payload.success ? payload.details : null; - } catch { - details = null; - } + // COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once + // all supported clients use checkout.forge.get_check_details.*. + details = await fetchBoundPullRequestCheckDetails({ + workspaceGit, + transport: canFetchForgeCheckDetails ? "forge" : "github", + repoOwner: data.repoOwner, + repoName: data.repoName, + checkRunId: ref.checkRunId, + workflowRunId: ref.workflowRunId, + changeRequestNumber: data.number, + }); } const attachment = buildPullRequestCheckContextAttachment({ provider: data.provider, @@ -444,8 +439,6 @@ export function PullRequestPane({ addWorkspaceAttachment, canFetchForgeCheckDetails, canFetchGitHubCheckDetails, - cwd, - daemonClient, data.forge, data.number, data.provider, @@ -454,6 +447,7 @@ export function PullRequestPane({ data.title, data.url, workspaceAttachmentScopeKey, + workspaceGit, ], ); @@ -483,6 +477,7 @@ export function PullRequestPane({ const nativeChecksSection = data.forgeSpecific ? nativeContribution?.renderChecksSection(data.forgeSpecific, { serverId, + workspaceId, cwd, changeRequestNumber: data.number, open: checksOpen, diff --git a/packages/app/src/git/pull-request-panel/query-keys.ts b/packages/app/src/git/pull-request-panel/query-keys.ts index 75bc3ef5ba..b204196dca 100644 --- a/packages/app/src/git/pull-request-panel/query-keys.ts +++ b/packages/app/src/git/pull-request-panel/query-keys.ts @@ -2,29 +2,40 @@ export const prPaneTimelineQueryKind = "prPaneTimeline"; export function prPaneTimelineQueryKey({ serverId, + queryIdentity, cwd, prNumber, }: { serverId: string; + queryIdentity: readonly ["selected", string]; cwd: string; prNumber: number | null; }) { - return [prPaneTimelineQueryKind, serverId, cwd, prNumber] as const; + return [prPaneTimelineQueryKind, serverId, queryIdentity, cwd, prNumber] as const; } export const prPanePipelineQueryKind = "prPanePipeline"; export function prPanePipelineQueryKey({ serverId, + queryIdentity, cwd, pipelineId, changeRequestNumber, }: { serverId: string; + queryIdentity: readonly ["selected", string]; cwd: string; pipelineId: number | null; /** MR iid the pipeline is fetched by; part of the key since the fetch routes by it. */ changeRequestNumber: number; }) { - return [prPanePipelineQueryKind, serverId, cwd, pipelineId, changeRequestNumber] as const; + return [ + prPanePipelineQueryKind, + serverId, + queryIdentity, + cwd, + pipelineId, + changeRequestNumber, + ] as const; } diff --git a/packages/app/src/git/pull-request-panel/use-data.test.ts b/packages/app/src/git/pull-request-panel/use-data.test.ts index 0e8a7edd97..644a1ebce9 100644 --- a/packages/app/src/git/pull-request-panel/use-data.test.ts +++ b/packages/app/src/git/pull-request-panel/use-data.test.ts @@ -16,7 +16,8 @@ import { type CheckoutPrStatus = NonNullable; type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"]; -type PullRequestTimelineInput = Parameters[0]; +type PullRequestTimelineInput = Parameters[0]; +const workspaceId = "workspace-1"; const githubStatus: CheckoutPrStatus["github"] = { mergeStateStatus: null, @@ -82,7 +83,7 @@ function createTimelineClient( const calls: PullRequestTimelineInput[] = []; return { calls, - pullRequestTimeline: async (input) => { + getPullRequestTimeline: async (input) => { calls.push(input); return respond(input); }, @@ -181,7 +182,12 @@ describe("shouldFetchTimelineFrom", () => { describe("createInMemoryUnsupportedTimelineRegistry", () => { it("remembers added keys and answers has() against them", () => { const registry = createInMemoryUnsupportedTimelineRegistry(); - const key = unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 }); + const key = unsupportedTimelineKey({ + serverId: "host", + workspaceId, + cwd: "/repo", + prNumber: 99, + }); expect(registry.has(key)).toBe(false); registry.add(key); @@ -189,14 +195,20 @@ describe("createInMemoryUnsupportedTimelineRegistry", () => { }); it("uses serverId, cwd, and prNumber to scope each key", () => { - expect(unsupportedTimelineKey({ serverId: "host-a", cwd: "/repo", prNumber: 1 })).not.toEqual( - unsupportedTimelineKey({ serverId: "host-b", cwd: "/repo", prNumber: 1 }), + expect( + unsupportedTimelineKey({ serverId: "host-a", workspaceId, cwd: "/repo", prNumber: 1 }), + ).not.toEqual( + unsupportedTimelineKey({ serverId: "host-b", workspaceId, cwd: "/repo", prNumber: 1 }), ); - expect(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-a", prNumber: 1 })).not.toEqual( - unsupportedTimelineKey({ serverId: "host", cwd: "/repo-b", prNumber: 1 }), + expect( + unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo-a", prNumber: 1 }), + ).not.toEqual( + unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo-b", prNumber: 1 }), ); - expect(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 1 })).not.toEqual( - unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 2 }), + expect( + unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo", prNumber: 1 }), + ).not.toEqual( + unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo", prNumber: 2 }), ); }); }); @@ -210,15 +222,14 @@ describe("fetchPrPaneTimelinePage", () => { client, registry, serverId: "host", + workspaceId, cwd: "/repo", prNumber: 42, repoOwner: "getpaseo", repoName: "paseo", }); - expect(client.calls).toEqual([ - { cwd: "/repo", prNumber: 42, repoOwner: "getpaseo", repoName: "paseo" }, - ]); + expect(client.calls).toEqual([{ prNumber: 42, repoOwner: "getpaseo", repoName: "paseo" }]); }); it("returns the daemon's timeline payload on success", async () => { @@ -241,6 +252,7 @@ describe("fetchPrPaneTimelinePage", () => { client, registry, serverId: "host", + workspaceId, cwd: "/repo", prNumber: 42, repoOwner: "getpaseo", @@ -262,6 +274,7 @@ describe("fetchPrPaneTimelinePage", () => { client, registry, serverId: "host", + workspaceId, cwd: "/repo", prNumber: 99, repoOwner: "getpaseo", @@ -270,7 +283,9 @@ describe("fetchPrPaneTimelinePage", () => { ).rejects.toBe(error); expect( - registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 })), + registry.has( + unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo", prNumber: 99 }), + ), ).toBe(true); }); @@ -286,6 +301,7 @@ describe("fetchPrPaneTimelinePage", () => { client, registry, serverId: "host", + workspaceId, cwd: "/repo", prNumber: 99, repoOwner: "getpaseo", @@ -294,16 +310,18 @@ describe("fetchPrPaneTimelinePage", () => { ).rejects.toBe(error); expect( - registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 })), + registry.has( + unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo", prNumber: 99 }), + ), ).toBe(false); }); it("scopes recorded tuples per serverId+cwd+prNumber so other PRs can still be tried", async () => { const client = createTimelineClient(async (input) => { - if (input.cwd === "/repo-a") { + if (input.prNumber === 1) { throw unsupportedTimelineError(); } - return timelinePayload({ cwd: input.cwd, prNumber: input.prNumber }); + return timelinePayload({ cwd: "/repo-b", prNumber: input.prNumber }); }); const registry = createInMemoryUnsupportedTimelineRegistry(); @@ -312,6 +330,7 @@ describe("fetchPrPaneTimelinePage", () => { client, registry, serverId: "host", + workspaceId, cwd: "/repo-a", prNumber: 1, repoOwner: "getpaseo", @@ -323,6 +342,7 @@ describe("fetchPrPaneTimelinePage", () => { client, registry, serverId: "host", + workspaceId, cwd: "/repo-b", prNumber: 2, repoOwner: "getpaseo", @@ -331,10 +351,14 @@ describe("fetchPrPaneTimelinePage", () => { expect(result.prNumber).toBe(2); expect( - registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-a", prNumber: 1 })), + registry.has( + unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo-a", prNumber: 1 }), + ), ).toBe(true); expect( - registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-b", prNumber: 2 })), + registry.has( + unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo-b", prNumber: 2 }), + ), ).toBe(false); }); }); diff --git a/packages/app/src/git/pull-request-panel/use-data.ts b/packages/app/src/git/pull-request-panel/use-data.ts index f43394cf7e..78fb84941e 100644 --- a/packages/app/src/git/pull-request-panel/use-data.ts +++ b/packages/app/src/git/pull-request-panel/use-data.ts @@ -1,12 +1,12 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { CheckoutPrStatusResponse, PullRequestTimelineResponse, } from "@getpaseo/protocol/messages"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { type WorkspaceGitClient, useRequiredWorkspaceGit } from "@/git/workspace-git"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import type { Forge } from "@/git/forge"; import { i18n } from "@/i18n/i18next"; @@ -19,7 +19,6 @@ type PullRequestTimeline = PullRequestTimelineResponse["payload"]; export interface UsePrPaneDataOptions { serverId: string; - cwd: string; enabled?: boolean; timelineEnabled?: boolean; } @@ -80,7 +79,7 @@ export function shouldFetchTimelineFrom({ ); } -export type PrPaneTimelineClient = Pick; +export type PrPaneTimelineClient = Pick; export interface UnsupportedTimelineRegistry { has(key: string): boolean; @@ -89,14 +88,16 @@ export interface UnsupportedTimelineRegistry { export function unsupportedTimelineKey({ serverId, + workspaceId, cwd, prNumber, }: { serverId: string; + workspaceId: string; cwd: string; prNumber: number; }): string { - return `${serverId}\0${cwd}\0${prNumber}`; + return `${serverId}\0${workspaceId}\0${cwd}\0${prNumber}`; } export function createInMemoryUnsupportedTimelineRegistry(): UnsupportedTimelineRegistry { @@ -115,6 +116,7 @@ export interface FetchPrPaneTimelinePageInput { client: PrPaneTimelineClient; registry: UnsupportedTimelineRegistry; serverId: string; + workspaceId: string; cwd: string; prNumber: number; repoOwner: string; @@ -125,8 +127,7 @@ export async function fetchPrPaneTimelinePage( input: FetchPrPaneTimelinePageInput, ): Promise { try { - return await input.client.pullRequestTimeline({ - cwd: input.cwd, + return await input.client.getPullRequestTimeline({ prNumber: input.prNumber, repoOwner: input.repoOwner, repoName: input.repoName, @@ -136,6 +137,7 @@ export async function fetchPrPaneTimelinePage( input.registry.add( unsupportedTimelineKey({ serverId: input.serverId, + workspaceId: input.workspaceId, cwd: input.cwd, prNumber: input.prNumber, }), @@ -197,14 +199,13 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR export function usePrPaneData({ serverId, - cwd, enabled = true, timelineEnabled = enabled, }: UsePrPaneDataOptions): UsePrPaneDataResult { const { t } = useTranslation(); - const daemonClient = useHostRuntimeClient(serverId); + const workspaceGit = useRequiredWorkspaceGit(); const isConnected = useHostRuntimeIsConnected(serverId); - const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled }); + const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, enabled }); const status = checkoutPrStatus.status; const identity = extractPrRepoIdentity(status); const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled; @@ -212,26 +213,37 @@ export function usePrPaneData({ const unsupportedKey = identity.prNumber === null ? null - : unsupportedTimelineKey({ serverId, cwd, prNumber: identity.prNumber }); + : unsupportedTimelineKey({ + serverId, + workspaceId: workspaceGit.workspaceId, + cwd: workspaceGit.cwd, + prNumber: identity.prNumber, + }); const timelineUnsupported = unsupportedKey ? registry.has(unsupportedKey) : false; const shouldFetchTimeline = shouldFetchTimelineFrom({ - hasClient: !!daemonClient, + hasClient: !!workspaceGit, isConnected, timelineEnabled, githubFeaturesEnabled, - cwd, + cwd: workspaceGit.cwd, identity, timelineUnsupported, }); const timelineQuery = useQuery({ queryKey: useMemo( - () => prPaneTimelineQueryKey({ serverId, cwd, prNumber: identity.prNumber }), - [serverId, cwd, identity.prNumber], + () => + prPaneTimelineQueryKey({ + serverId, + queryIdentity: workspaceGit.queryIdentity, + cwd: workspaceGit.cwd, + prNumber: identity.prNumber, + }), + [serverId, workspaceGit, identity.prNumber], ), queryFn: async () => { if ( - !daemonClient || + !workspaceGit || identity.prNumber === null || identity.repoOwner === null || identity.repoName === null @@ -239,10 +251,11 @@ export function usePrPaneData({ throw new Error(t("common.errors.daemonClientUnavailable")); } return fetchPrPaneTimelinePage({ - client: daemonClient, + client: workspaceGit, registry, serverId, - cwd, + workspaceId: workspaceGit.workspaceId, + cwd: workspaceGit.cwd, prNumber: identity.prNumber, repoOwner: identity.repoOwner, repoName: identity.repoName, diff --git a/packages/app/src/git/pull-request-panel/use-pipeline.ts b/packages/app/src/git/pull-request-panel/use-pipeline.ts index 91b020a0ea..66e49792b9 100644 --- a/packages/app/src/git/pull-request-panel/use-pipeline.ts +++ b/packages/app/src/git/pull-request-panel/use-pipeline.ts @@ -1,8 +1,9 @@ import { useMemo } from "react"; import type { CheckoutPipeline } from "@getpaseo/protocol/messages"; import { useFetchQuery } from "@/data/query"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { prPanePipelineQueryKey } from "./query-keys"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; /** Poll cadence for an in-progress pipeline; finished pipelines are immutable. */ const LIVE_PIPELINE_REFETCH_MS = 15_000; @@ -10,7 +11,6 @@ const FINISHED_PIPELINE_STALE_MS = 24 * 60 * 60 * 1_000; export interface UseGitLabPipelineOptions { serverId: string; - cwd: string; pipelineId: number | null; /** MR iid, so the fetch resolves a fork/detached head pipeline correctly. */ changeRequestNumber: number; @@ -38,27 +38,32 @@ export interface UseGitLabPipelineResult { */ export function useGitLabPipeline({ serverId, - cwd, pipelineId, changeRequestNumber, enabled, live, }: UseGitLabPipelineOptions): UseGitLabPipelineResult { - const daemonClient = useHostRuntimeClient(serverId); + const workspaceGit = useRequiredWorkspaceGit(); const isConnected = useHostRuntimeIsConnected(serverId); - const shouldFetch = enabled && !!daemonClient && isConnected && !!cwd && pipelineId !== null; + const shouldFetch = enabled && !!workspaceGit && isConnected && pipelineId !== null; const query = useFetchQuery({ queryKey: useMemo( - () => prPanePipelineQueryKey({ serverId, cwd, pipelineId, changeRequestNumber }), - [serverId, cwd, pipelineId, changeRequestNumber], + () => + prPanePipelineQueryKey({ + serverId, + queryIdentity: workspaceGit.queryIdentity, + cwd: workspaceGit.cwd, + pipelineId, + changeRequestNumber, + }), + [serverId, workspaceGit, pipelineId, changeRequestNumber], ), queryFn: async () => { - if (!daemonClient || pipelineId === null) { + if (!workspaceGit || pipelineId === null) { return null; } - const payload = await daemonClient.checkoutForgeGetCheckDetails({ - cwd, + const payload = await workspaceGit.getForgeCheckDetails({ checkRunId: pipelineId, changeRequestNumber, }); diff --git a/packages/app/src/git/query-keys.test.ts b/packages/app/src/git/query-keys.test.ts index e6caa3cc30..7033409c9b 100644 --- a/packages/app/src/git/query-keys.test.ts +++ b/packages/app/src/git/query-keys.test.ts @@ -1,8 +1,8 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import { - checkoutDiffQueryKey, checkoutCommitsQueryKey, + checkoutDiffQueryKey, checkoutPrStatusQueryKey, checkoutStatusQueryKey, invalidateCheckoutGitQueriesForClient, @@ -15,153 +15,156 @@ import { describe("checkout query keys", () => { const serverId = "server-1"; - const cwd = "/tmp/repo"; + const target = { + workspaceId: "workspace-a", + cwd: "/tmp/repo", + queryIdentity: ["selected", "workspace-a"] as const, + }; + const sibling = { + workspaceId: "workspace-b", + cwd: target.cwd, + queryIdentity: ["selected", "workspace-b"] as const, + }; - it("invalidates every query for a checkout without touching other checkouts", async () => { - const queryClient = new QueryClient(); + it("uses workspace identity before the compatibility cwd", () => { + expect(checkoutStatusQueryKey(serverId, target)).toEqual([ + "checkoutStatus", + serverId, + target.queryIdentity, + target.cwd, + ]); + expect(checkoutStatusQueryKey(serverId, sibling)).not.toEqual( + checkoutStatusQueryKey(serverId, target), + ); + }); - queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true }); - queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), { - files: [], - }); - queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } }); - queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] }); - queryClient.setQueryData(checkoutCommitsQueryKey(serverId, "/tmp/other"), { commits: [] }); - queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), { - items: [], - }); - queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }), { - items: [], - }); - queryClient.setQueryData( - prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }), - { - stages: [], - }, + it("keeps selected and legacy identity distinct across keys and invalidation", async () => { + const selected = { + ...target, + workspaceId: "legacy:/shared", + cwd: "/shared", + queryIdentity: ["selected", "legacy:/shared"] as const, + }; + const legacy = { + cwd: "/shared", + queryIdentity: ["legacy", "/shared"] as const, + }; + + expect(checkoutStatusQueryKey(serverId, selected)).not.toEqual( + checkoutStatusQueryKey(serverId, legacy), ); - queryClient.setQueryData( - prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }), - { items: [] }, + expect(checkoutPrStatusQueryKey(serverId, selected)).not.toEqual( + checkoutPrStatusQueryKey(serverId, legacy), ); - queryClient.setQueryData( - prPanePipelineQueryKey({ - serverId, - cwd: "/tmp/other", - pipelineId: 9001, - changeRequestNumber: 1, - }), - { stages: [] }, + expect(checkoutDiffQueryKey(serverId, selected, "uncommitted")).toContainEqual( + selected.queryIdentity, ); + expect(checkoutCommitsQueryKey(serverId, selected)).toContainEqual(selected.queryIdentity); - await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd }); + const queryClient = new QueryClient(); + queryClient.setQueryData(checkoutStatusQueryKey(serverId, selected), { branch: "selected" }); + queryClient.setQueryData(checkoutStatusQueryKey(serverId, legacy), { branch: "legacy" }); + queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, selected), { status: "selected" }); + queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, legacy), { status: "legacy" }); + queryClient.setQueryData(checkoutDiffQueryKey(serverId, selected, "uncommitted"), { + files: ["selected"], + }); + queryClient.setQueryData(checkoutCommitsQueryKey(serverId, selected), { + commits: ["selected"], + }); - expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, cwd))?.isInvalidated).toBe( - true, + await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, target: selected }); + + expect( + queryClient.getQueryState(checkoutStatusQueryKey(serverId, selected))?.isInvalidated, + ).toBe(true); + expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, legacy))?.isInvalidated).toBe( + false, ); expect( - queryClient.getQueryState(checkoutDiffQueryKey(serverId, cwd, "base", "main", true)) + queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, selected))?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, legacy))?.isInvalidated, + ).toBe(false); + expect( + queryClient.getQueryState(checkoutDiffQueryKey(serverId, selected, "uncommitted")) ?.isInvalidated, ).toBe(true); - expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe( - true, - ); - expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe( + expect( + queryClient.getQueryState(checkoutCommitsQueryKey(serverId, selected))?.isInvalidated, + ).toBe(true); + }); + + it("invalidates one selected workspace without touching a same-cwd sibling", async () => { + const queryClient = new QueryClient(); + for (const selected of [target, sibling]) { + queryClient.setQueryData(checkoutStatusQueryKey(serverId, selected), { isGit: true }); + queryClient.setQueryData(checkoutDiffQueryKey(serverId, selected, "base", "main", true), { + files: [], + }); + queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, selected), { status: null }); + queryClient.setQueryData(checkoutCommitsQueryKey(serverId, selected), { commits: [] }); + queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, ...selected, prNumber: 12 }), { + items: [], + }); + queryClient.setQueryData( + prPanePipelineQueryKey({ serverId, ...selected, pipelineId: 9, changeRequestNumber: 12 }), + { stages: [] }, + ); + } + + await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, target }); + + expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, target))?.isInvalidated).toBe( true, ); expect( - queryClient.getQueryState(checkoutCommitsQueryKey(serverId, "/tmp/other"))?.isInvalidated, - ).toBe(false); - expect( - queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 })) + queryClient.getQueryState(checkoutDiffQueryKey(serverId, target, "base", "main", true)) ?.isInvalidated, ).toBe(true); expect( - queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 })) + queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, target))?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(checkoutCommitsQueryKey(serverId, target))?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, ...target, prNumber: 12 })) ?.isInvalidated, ).toBe(true); expect( queryClient.getQueryState( - prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }), + prPanePipelineQueryKey({ serverId, ...target, pipelineId: 9, changeRequestNumber: 12 }), )?.isInvalidated, ).toBe(true); expect( - queryClient.getQueryState( - prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }), - )?.isInvalidated, + queryClient.getQueryState(checkoutStatusQueryKey(serverId, sibling))?.isInvalidated, ).toBe(false); expect( - queryClient.getQueryState( - prPanePipelineQueryKey({ - serverId, - cwd: "/tmp/other", - pipelineId: 9001, - changeRequestNumber: 1, - }), - )?.isInvalidated, + queryClient.getQueryState(checkoutCommitsQueryKey(serverId, sibling))?.isInvalidated, ).toBe(false); - - queryClient.clear(); }); - it("invalidates fetch-based checkout queries server-wide without touching other servers", async () => { + it("invalidates fetch-based selected queries server-wide but leaves diffs subscription-fed", async () => { const queryClient = new QueryClient(); - const otherServerId = "server-2"; - const otherCwd = "/tmp/repo-2"; - - queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true }); - queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), { isGit: true }); - queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } }); - queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] }); - queryClient.setQueryData(checkoutCommitsQueryKey(otherServerId, cwd), { commits: [] }); - queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), { - items: [], - }); - queryClient.setQueryData( - prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }), - { - stages: [], - }, - ); - // Subscription-fed diff queries are deliberately not part of the server-wide sweep. - queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), { + queryClient.setQueryData(checkoutStatusQueryKey(serverId, target), { isGit: true }); + queryClient.setQueryData(checkoutDiffQueryKey(serverId, target, "base", "main", true), { files: [], }); - queryClient.setQueryData(checkoutStatusQueryKey(otherServerId, cwd), { isGit: true }); + queryClient.setQueryData(checkoutStatusQueryKey("server-2", target), { isGit: true }); await invalidateCheckoutGitQueriesForServer(queryClient, serverId); - expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, cwd))?.isInvalidated).toBe( - true, - ); - expect( - queryClient.getQueryState(checkoutStatusQueryKey(serverId, otherCwd))?.isInvalidated, - ).toBe(true); - expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe( - true, - ); - expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe( + expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, target))?.isInvalidated).toBe( true, ); expect( - queryClient.getQueryState(checkoutCommitsQueryKey(otherServerId, cwd))?.isInvalidated, - ).toBe(false); - expect( - queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 })) - ?.isInvalidated, - ).toBe(true); - expect( - queryClient.getQueryState( - prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }), - )?.isInvalidated, - ).toBe(true); - expect( - queryClient.getQueryState(checkoutDiffQueryKey(serverId, cwd, "base", "main", true)) + queryClient.getQueryState(checkoutDiffQueryKey(serverId, target, "base", "main", true)) ?.isInvalidated, ).toBe(false); expect( - queryClient.getQueryState(checkoutStatusQueryKey(otherServerId, cwd))?.isInvalidated, + queryClient.getQueryState(checkoutStatusQueryKey("server-2", target))?.isInvalidated, ).toBe(false); - - queryClient.clear(); }); }); diff --git a/packages/app/src/git/query-keys.ts b/packages/app/src/git/query-keys.ts index cfd804e459..f925740846 100644 --- a/packages/app/src/git/query-keys.ts +++ b/packages/app/src/git/query-keys.ts @@ -1,14 +1,20 @@ import type { Query, QueryClient } from "@tanstack/react-query"; +import { + workspaceGitQueryIdentitiesEqual, + type WorkspaceGitQueryIdentity, + type WorkspaceGitStatusTarget, + type WorkspaceGitTarget, +} from "./workspace-git"; import { prPanePipelineQueryKind, prPaneTimelineQueryKind } from "./pull-request-panel/query-keys"; interface CheckoutQueryIdentity { serverId: string; - cwd: string; + target: WorkspaceGitTarget; } interface CheckoutQueryScope { serverId: string; - cwd?: string; + target?: WorkspaceGitTarget; } type CheckoutQueryKey = readonly unknown[]; @@ -17,35 +23,47 @@ type CheckoutQueryKey = readonly unknown[]; // can share the same long-lived cache policy. export const COMMIT_FILE_DIFF_STALE_TIME = 5 * 60_000; -export function checkoutStatusQueryKey(serverId: string, cwd: string) { - return ["checkoutStatus", serverId, cwd] as const; +export function checkoutStatusQueryKey(serverId: string, target: WorkspaceGitStatusTarget) { + return ["checkoutStatus", serverId, target.queryIdentity, target.cwd] as const; +} + +export function legacyCheckoutStatusQueryKey(serverId: string, cwd: string) { + return ["legacyCheckoutStatus", serverId, cwd] as const; } export function checkoutDiffQueryKey( serverId: string, - cwd: string, + target: WorkspaceGitTarget, mode: "uncommitted" | "base", baseRef?: string, ignoreWhitespace?: boolean, ) { - return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const; + return [ + "checkoutDiff", + serverId, + target.queryIdentity, + target.cwd, + mode, + baseRef ?? "", + ignoreWhitespace === true, + ] as const; } -export function checkoutPrStatusQueryKey(serverId: string, cwd: string) { - return ["checkoutPrStatus", serverId, cwd] as const; +export function checkoutPrStatusQueryKey(serverId: string, target: WorkspaceGitStatusTarget) { + return ["checkoutPrStatus", serverId, target.queryIdentity, target.cwd] as const; } -export function checkoutCommitsQueryKey(serverId: string, cwd: string) { - return ["checkoutCommits", serverId, cwd] as const; +export function checkoutCommitsQueryKey(serverId: string, target: WorkspaceGitTarget) { + return ["checkoutCommits", serverId, target.queryIdentity, target.cwd] as const; } export function checkoutCommitFileDiffQueryKey( serverId: string, - cwd: string, + target: WorkspaceGitTarget, sha: string, path: string, ) { - return ["checkoutCommitFileDiff", serverId, cwd, sha, path] as const; + return ["checkoutCommitFileDiff", serverId, target.queryIdentity, target.cwd, sha, path] as const; } export async function invalidateCheckoutGitQueriesForClient( @@ -54,7 +72,7 @@ export async function invalidateCheckoutGitQueriesForClient( ) { await Promise.all([ queryClient.invalidateQueries({ - queryKey: checkoutStatusQueryKey(identity.serverId, identity.cwd), + queryKey: checkoutStatusQueryKey(identity.serverId, identity.target), }), queryClient.invalidateQueries({ predicate: checkoutQueryPredicate("checkoutDiff", identity), @@ -63,7 +81,7 @@ export async function invalidateCheckoutGitQueriesForClient( predicate: checkoutQueryPredicate("checkoutPrStatus", identity), }), queryClient.invalidateQueries({ - queryKey: checkoutCommitsQueryKey(identity.serverId, identity.cwd), + queryKey: checkoutCommitsQueryKey(identity.serverId, identity.target), }), queryClient.invalidateQueries({ predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity), @@ -119,16 +137,28 @@ function checkoutQueryPredicate( isCheckoutQueryKey(key) && key[0] === queryKind && key[1] === scope.serverId && - (scope.cwd === undefined || key[2] === scope.cwd) + (scope.target === undefined || + (isWorkspaceGitQueryIdentity(key[2]) && + workspaceGitQueryIdentitiesEqual(key[2], scope.target.queryIdentity) && + key[3] === scope.target.cwd)) ); }; } function isCheckoutQueryKey(key: readonly unknown[]): key is CheckoutQueryKey { return ( - key.length >= 3 && + key.length >= 4 && typeof key[0] === "string" && typeof key[1] === "string" && - typeof key[2] === "string" + isWorkspaceGitQueryIdentity(key[2]) && + typeof key[3] === "string" + ); +} + +function isWorkspaceGitQueryIdentity(value: unknown): value is WorkspaceGitQueryIdentity { + return ( + Array.isArray(value) && + (value[0] === "selected" || value[0] === "legacy") && + typeof value[1] === "string" ); } diff --git a/packages/app/src/git/use-actions.tsx b/packages/app/src/git/use-actions.tsx index 05e65a21a5..4cecaa05b9 100644 --- a/packages/app/src/git/use-actions.tsx +++ b/packages/app/src/git/use-actions.tsx @@ -30,6 +30,7 @@ import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-ar import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; import { readValidatedString } from "@/storage/validated-storage"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; @@ -319,7 +320,8 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("pr"); - const { status, isLoading: isStatusLoading } = useCheckoutStatusQuery({ serverId, cwd }); + const target = useRequiredWorkspaceGit(); + const { status, isLoading: isStatusLoading } = useCheckoutStatusQuery({ serverId }); const gitStatus = status && status.isGit ? status : null; const isGit = Boolean(gitStatus); const notGit = status !== null && !status.isGit && !status.error; @@ -333,7 +335,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use forge, } = useCheckoutPrStatusQuery({ serverId, - cwd, enabled: isGit, }); const prIcon = useMemo(() => renderForgePrIcon(forge), [forge]); @@ -397,50 +398,50 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use }, [cwd]); const commitStatus = useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "commit" }), + s.getStatus({ serverId, target, actionId: "commit" }), ); const pullStatus = useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "pull" }), + s.getStatus({ serverId, target, actionId: "pull" }), ); const pushStatus = useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "push" }), + s.getStatus({ serverId, target, actionId: "push" }), ); const pullAndPushStatus = useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "pull-and-push" }), + s.getStatus({ serverId, target, actionId: "pull-and-push" }), ); const prCreateStatus = useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "create-pr" }), + s.getStatus({ serverId, target, actionId: "create-pr" }), ); const mergePrStatuses: Record = { squash: useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "merge-pr-squash" }), + s.getStatus({ serverId, target, actionId: "merge-pr-squash" }), ), merge: useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "merge-pr-merge" }), + s.getStatus({ serverId, target, actionId: "merge-pr-merge" }), ), rebase: useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "merge-pr-rebase" }), + s.getStatus({ serverId, target, actionId: "merge-pr-rebase" }), ), }; const enablePrAutoMergeStatuses: Record = { squash: useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }), + s.getStatus({ serverId, target, actionId: "enable-pr-auto-merge-squash" }), ), merge: useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }), + s.getStatus({ serverId, target, actionId: "enable-pr-auto-merge-merge" }), ), rebase: useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-rebase" }), + s.getStatus({ serverId, target, actionId: "enable-pr-auto-merge-rebase" }), ), }; const disablePrAutoMergeStatus = useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }), + s.getStatus({ serverId, target, actionId: "disable-pr-auto-merge" }), ); const mergeStatus = useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "merge-branch" }), + s.getStatus({ serverId, target, actionId: "merge-branch" }), ); const mergeFromBaseStatus = useCheckoutGitActionsStore((s) => - s.getStatus({ serverId, cwd, actionId: "merge-from-base" }), + s.getStatus({ serverId, target, actionId: "merge-from-base" }), ); const runCommit = useCheckoutGitActionsStore((s) => s.commit); @@ -476,7 +477,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use // Handlers const handleCommit = useCallback(() => { - void runCommit({ serverId, cwd }) + void runCommit({ serverId, target }) .then(() => { toastActionSuccess(t("workspace.git.actions.commit.success")); return; @@ -484,10 +485,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use .catch((err) => { toastActionError(err, t("workspace.git.actions.toasts.failedCommit")); }); - }, [cwd, runCommit, serverId, t, toastActionError, toastActionSuccess]); + }, [runCommit, serverId, t, target, toastActionError, toastActionSuccess]); const handlePull = useCallback(() => { - void runPull({ serverId, cwd }) + void runPull({ serverId, target }) .then(() => { toastActionSuccess(t("workspace.git.actions.pull.success")); return; @@ -495,10 +496,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use .catch((err) => { toastActionError(err, t("workspace.git.actions.toasts.failedPull")); }); - }, [cwd, runPull, serverId, t, toastActionError, toastActionSuccess]); + }, [runPull, serverId, t, target, toastActionError, toastActionSuccess]); const handlePush = useCallback(() => { - void runPush({ serverId, cwd }) + void runPush({ serverId, target }) .then(() => { toastActionSuccess(t("workspace.git.actions.push.success")); return; @@ -506,10 +507,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use .catch((err) => { toastActionError(err, t("workspace.git.actions.toasts.failedPush")); }); - }, [cwd, runPush, serverId, t, toastActionError, toastActionSuccess]); + }, [runPush, serverId, t, target, toastActionError, toastActionSuccess]); const handlePullAndPush = useCallback(() => { - void runPullAndPush({ serverId, cwd }) + void runPullAndPush({ serverId, target }) .then(() => { toastActionSuccess(t("workspace.git.actions.pullAndPush.success")); return; @@ -517,11 +518,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use .catch((err) => { toastActionError(err, t("workspace.git.actions.toasts.failedPullAndPush")); }); - }, [cwd, runPullAndPush, serverId, t, toastActionError, toastActionSuccess]); + }, [runPullAndPush, serverId, t, target, toastActionError, toastActionSuccess]); const handleCreatePr = useCallback(() => { void persistShipDefault("pr"); - void runCreatePr({ serverId, cwd }) + void runCreatePr({ serverId, target }) .then(() => { toastActionSuccess(t("workspace.git.actions.createPr.success", forgeVocabulary(forge))); return; @@ -530,12 +531,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use toastActionError(err, t("workspace.git.actions.toasts.failedCreatePr")); }); }, [ - cwd, forge, persistShipDefault, runCreatePr, serverId, t, + target, toastActionError, toastActionSuccess, ]); @@ -543,7 +544,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const handleMergePr = useCallback( (method: CheckoutPrMergeMethod) => { void persistShipDefault("pr"); - void runMergePr({ serverId, cwd, method }) + void runMergePr({ serverId, target, method }) .then(() => { setPostShipArchiveSuggested(true); toastActionSuccess(t("workspace.git.actions.mergePr.success", forgeVocabulary(forge))); @@ -553,13 +554,22 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use toastActionError(err, t("workspace.git.actions.toasts.failedMergePr")); }); }, - [cwd, forge, persistShipDefault, runMergePr, serverId, t, toastActionError, toastActionSuccess], + [ + forge, + persistShipDefault, + runMergePr, + serverId, + t, + target, + toastActionError, + toastActionSuccess, + ], ); const handleEnablePrAutoMerge = useCallback( (method: CheckoutPrMergeMethod) => { void persistShipDefault("pr"); - void runEnablePrAutoMerge({ serverId, cwd, method }) + void runEnablePrAutoMerge({ serverId, target, method }) .then(() => { toastActionSuccess(t("workspace.git.actions.autoMerge.enabled")); return; @@ -569,18 +579,18 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use }); }, [ - cwd, persistShipDefault, runEnablePrAutoMerge, serverId, t, + target, toastActionError, toastActionSuccess, ], ); const handleDisablePrAutoMerge = useCallback(() => { - void runDisablePrAutoMerge({ serverId, cwd }) + void runDisablePrAutoMerge({ serverId, target }) .then(() => { toastActionSuccess(t("workspace.git.actions.autoMerge.disabled")); return; @@ -588,7 +598,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use .catch((err) => { toastActionError(err, t("workspace.git.actions.toasts.failedDisableAutoMerge")); }); - }, [cwd, runDisablePrAutoMerge, serverId, t, toastActionError, toastActionSuccess]); + }, [runDisablePrAutoMerge, serverId, t, target, toastActionError, toastActionSuccess]); const handleMergeBranch = useCallback(() => { if (!baseRef) { @@ -596,7 +606,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use return; } void persistShipDefault("merge"); - void runMergeBranch({ serverId, cwd, baseRef }) + void runMergeBranch({ serverId, target, baseRef }) .then(() => { setPostShipArchiveSuggested(true); toastActionSuccess(t("workspace.git.actions.mergeBranch.success")); @@ -607,11 +617,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use }); }, [ baseRef, - cwd, persistShipDefault, runMergeBranch, serverId, t, + target, toast, toastActionError, toastActionSuccess, @@ -622,7 +632,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use toast.error(t("workspace.git.actions.toasts.baseRefUnavailable")); return; } - void runMergeFromBase({ serverId, cwd, baseRef }) + void runMergeFromBase({ serverId, target, baseRef }) .then(() => { toastActionSuccess(t("workspace.git.actions.mergeFromBase.success")); return; @@ -630,7 +640,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use .catch((err) => { toastActionError(err, t("workspace.git.actions.toasts.failedMergeFromBase")); }); - }, [baseRef, cwd, runMergeFromBase, serverId, t, toast, toastActionError, toastActionSuccess]); + }, [baseRef, runMergeFromBase, serverId, t, target, toast, toastActionError, toastActionSuccess]); const archiveController = useWorkspaceScreenArchiveController({ serverId, diff --git a/packages/app/src/git/use-commits-query.ts b/packages/app/src/git/use-commits-query.ts index bb77f50942..188db6909e 100644 --- a/packages/app/src/git/use-commits-query.ts +++ b/packages/app/src/git/use-commits-query.ts @@ -3,8 +3,9 @@ import invariant from "tiny-invariant"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useFetchQuery } from "@/data/query"; import { checkoutCommitsQueryKey } from "@/git/query-keys"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; // Commits ahead of base change rarely while the section is open; this keeps a // collapse/re-expand cycle warm without leaving the fetch result stale for long. @@ -12,7 +13,6 @@ const CHECKOUT_COMMITS_STALE_TIME = 30_000; interface UseCheckoutCommitsQueryOptions { serverId: string; - cwd: string; enabled?: boolean; } @@ -70,12 +70,11 @@ export function resolveCheckoutCommitsQueryResult({ export function useCheckoutCommitsQuery({ serverId, - cwd, enabled = true, }: UseCheckoutCommitsQueryOptions): CheckoutCommitsQueryResult { const retainedPanelActive = useRetainedPanelActive(); const queryEnabledByCaller = enabled && retainedPanelActive; - const client = useHostRuntimeClient(serverId); + const workspaceGit = useRequiredWorkspaceGit(); const isConnected = useHostRuntimeIsConnected(serverId); // COMPAT(commitsList): added in v0.1.110, remove after 2027-01-16. // COMPAT(commitBaseClassification): added in v0.2.0, remove after 2027-01-23. @@ -86,16 +85,16 @@ export function useCheckoutCommitsQuery({ state.sessions[serverId]?.serverInfo?.features?.commitBaseClassification === true, ); - const canFetch = Boolean(cwd) && Boolean(client) && isConnected; + const canFetch = Boolean(workspaceGit) && isConnected; const queryEnabled = queryEnabledByCaller && capabilityPresent && canFetch; const query = useFetchQuery({ - queryKey: checkoutCommitsQueryKey(serverId, cwd), + queryKey: checkoutCommitsQueryKey(serverId, workspaceGit), queryFn: async () => { - if (!client) { + if (!workspaceGit) { throw new Error("Host disconnected"); } - const data = await client.listCheckoutCommits(cwd); + const data = await workspaceGit.listCommits(); const commits = data.commits.map((commit) => { invariant(commit.isOnBase !== undefined, "Host omitted commit base classification"); return { ...commit, isOnBase: commit.isOnBase }; diff --git a/packages/app/src/git/use-diff-files.ts b/packages/app/src/git/use-diff-files.ts index 14a17edcb1..7151002496 100644 --- a/packages/app/src/git/use-diff-files.ts +++ b/packages/app/src/git/use-diff-files.ts @@ -4,7 +4,8 @@ import { useRetainedPanelActive } from "@/components/retained-panel"; import { useFetchQueries } from "@/data/query"; import { checkoutCommitFileDiffQueryKey, COMMIT_FILE_DIFF_STALE_TIME } from "@/git/query-keys"; import { useCheckoutCommitsQuery } from "@/git/use-commits-query"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; /** * Context needed to resolve a commit diff against a host: which daemon @@ -13,7 +14,6 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host- */ export interface CommitDiffFilesContext { serverId: string; - cwd: string; sha: string; enabled?: boolean; } @@ -57,12 +57,12 @@ export function resolveCommitDiffFiles( } export function useCommitDiffFiles(ctx: CommitDiffFilesContext): CommitDiffFilesResult { - const { serverId, cwd, sha, enabled = true } = ctx; + const { serverId, sha, enabled = true } = ctx; + const workspaceGit = useRequiredWorkspaceGit(); const retainedPanelActive = useRetainedPanelActive(); const queryEnabled = enabled && retainedPanelActive; - const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - const commitsQuery = useCheckoutCommitsQuery({ serverId, cwd, enabled: queryEnabled }); + const commitsQuery = useCheckoutCommitsQuery({ serverId, enabled: queryEnabled }); const commitsData = commitsQuery.status === "loaded" ? commitsQuery.data : null; const commitFiles = useMemo(() => { if (!sha || !commitsData) { @@ -74,18 +74,17 @@ export function useCommitDiffFiles(ctx: CommitDiffFilesContext): CommitDiffFiles const fileDiffsEnabled = queryEnabled && commitsQuery.status === "loaded" && - Boolean(cwd) && + Boolean(workspaceGit) && Boolean(sha) && - Boolean(client) && isConnected; const fileDiffResults = useFetchQueries( commitFiles.map((file) => ({ - queryKey: checkoutCommitFileDiffQueryKey(serverId, cwd, sha, file.path), + queryKey: checkoutCommitFileDiffQueryKey(serverId, workspaceGit, sha, file.path), queryFn: async (): Promise<{ file: ParsedDiffFile | null }> => { - if (!client) { + if (!workspaceGit) { throw new Error("Host disconnected"); } - return client.getCommitFileDiff(cwd, sha, file.path); + return workspaceGit.getCommitFileDiff(sha, file.path); }, enabled: fileDiffsEnabled, staleTimeMs: COMMIT_FILE_DIFF_STALE_TIME, diff --git a/packages/app/src/git/use-diff-query.ts b/packages/app/src/git/use-diff-query.ts index 7002bbfc55..47a99a97c0 100644 --- a/packages/app/src/git/use-diff-query.ts +++ b/packages/app/src/git/use-diff-query.ts @@ -5,10 +5,10 @@ import { checkoutDiffPushRoute } from "@/data/push-router"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages"; import { checkoutDiffQueryKey } from "@/git/query-keys"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; interface UseCheckoutDiffQueryOptions { serverId: string; - cwd: string; mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean; @@ -41,13 +41,13 @@ function normalizeCheckoutDiffCompare(compare: { export function useCheckoutDiffQuery({ serverId, - cwd, mode, baseRef, ignoreWhitespace, enabled = true, queryScope, }: UseCheckoutDiffQueryOptions) { + const workspaceGit = useRequiredWorkspaceGit(); const retainedPanelActive = useRetainedPanelActive(); const queryEnabled = enabled && retainedPanelActive; const isConnected = useHostRuntimeIsConnected(serverId); @@ -61,16 +61,16 @@ export function useCheckoutDiffQuery({ const queryKey = useMemo(() => { const comparisonKey = checkoutDiffQueryKey( serverId, - cwd, + workspaceGit, compareMode, compareBaseRef, compareIgnoreWhitespace, ); const normalizedScope = queryScope?.trim(); return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey; - }, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]); + }, [serverId, workspaceGit, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]); const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]); - const routeEnabled = Boolean(queryEnabled && isConnected && cwd); + const routeEnabled = Boolean(queryEnabled && isConnected && workspaceGit); const query = useReplicaQuery({ queryKey, @@ -80,7 +80,7 @@ export function useCheckoutDiffQuery({ enabled: routeEnabled, serverId, subscriptionId, - cwd, + workspaceGit, compare: { mode: compareMode, ...(compareBaseRef ? { baseRef: compareBaseRef } : {}), diff --git a/packages/app/src/git/use-forge-search-query.test.ts b/packages/app/src/git/use-forge-search-query.test.ts index 5cc800a9b4..d7119e11ff 100644 --- a/packages/app/src/git/use-forge-search-query.test.ts +++ b/packages/app/src/git/use-forge-search-query.test.ts @@ -1,282 +1,75 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { buildForgeSearchQueryOptions, forgeSearchQueryKey } from "./use-forge-search-query"; -describe("forgeSearchQueryKey", () => { - it("keeps the shared cache key shape for no-kinds searches", () => { - expect(forgeSearchQueryKey("server-1", "/repo", " 123 ")).toEqual([ - "forge-search", - "server-1", - "/repo", - "forge", - "123", - ]); +const target = { + cwd: "/workspace", + queryIdentity: ["selected", "workspace-a"] as const, +}; + +describe("forge search query", () => { + it("keys same-cwd workspaces independently", () => { + expect(forgeSearchQueryKey("server", target, " issue ")).not.toEqual( + forgeSearchQueryKey( + "server", + { ...target, queryIdentity: ["selected", "workspace-b"] }, + "issue", + ), + ); }); - it("adds a deterministic kinds key when kinds are specified", () => { - expect(forgeSearchQueryKey("server-1", "/repo", "123", ["change_request", "issue"])).toEqual([ - "forge-search", - "server-1", - "/repo", - "forge", - "123", - "change_request,issue", - ]); + it("does not alias a selected id that resembles a legacy address", () => { + expect( + forgeSearchQueryKey( + "server", + { cwd: "/shared", queryIdentity: ["selected", "legacy:/shared"] }, + "issue", + ), + ).not.toEqual( + forgeSearchQueryKey( + "server", + { cwd: "/shared", queryIdentity: ["legacy", "/shared"] }, + "issue", + ), + ); }); - it("separates legacy GitHub fallback results from forge search results", () => { - expect(forgeSearchQueryKey("server-1", "/repo", "123", undefined, "github")).toEqual([ - "forge-search", - "server-1", - "/repo", - "github", - "123", - ]); - }); -}); - -describe("buildForgeSearchQueryOptions", () => { - it("forwards kinds to the forge search request when specified", async () => { - const requests: unknown[] = []; - const query = buildForgeSearchQueryOptions({ - client: { - async searchForge(options) { - requests.push(options); - return { - items: [], - authState: "authenticated", - error: null, - requestId: "request-1", - }; - }, - }, - serverId: "server-1", - cwd: "/repo", - query: " 123 ", - kinds: ["change_request"], - enabled: true, - }); - - await query.queryFn(); - - expect(requests).toEqual([ - { cwd: "/repo", query: "123", limit: 20, kinds: ["change_request"] }, - ]); - }); - - it("uses the legacy GitHub search request when forge search is unsupported", async () => { - const forgeRequests: unknown[] = []; - const githubRequests: unknown[] = []; - const query = buildForgeSearchQueryOptions({ - client: { - async searchForge(options) { - forgeRequests.push(options); - return { - items: [], - authState: "authenticated", - error: null, - requestId: "forge-request", - }; - }, - async searchGitHub(options) { - githubRequests.push(options); - return { - items: [], - featuresEnabled: true, - authState: "authenticated", - githubFeaturesEnabled: true, - error: null, - requestId: "github-request", - }; - }, - }, - serverId: "server-1", - cwd: "/repo", - query: " 456 ", - kinds: ["issue"], - enabled: true, - supportsForgeSearch: false, - }); - - await query.queryFn(); - - expect(forgeRequests).toEqual([]); - expect(githubRequests).toEqual([ - { cwd: "/repo", query: "456", limit: 20, kinds: ["github-issue"] }, - ]); - }); - - it("normalizes legacy GitHub PR items into neutral change-request items", async () => { - const query = buildForgeSearchQueryOptions({ - client: { - async searchForge() { - throw new Error("unexpected forge search"); - }, - async searchGitHub() { - return { - items: [ - { - kind: "pr" as const, - number: 17, - title: "Fix search", - url: "https://github.com/acme/repo/pull/17", - state: "open", - body: null, - labels: ["bug"], - baseRefName: "main", - headRefName: "fix-search", - }, - ], - featuresEnabled: true, - authState: "authenticated" as const, - githubFeaturesEnabled: true, - error: null, - requestId: "github-request", - }; - }, - }, - serverId: "server-1", - cwd: "/repo", - query: " 456 ", - enabled: true, - supportsForgeSearch: false, - }); - - const result = await query.queryFn(); - - expect(result.items).toEqual([ - { - kind: "change_request", - number: 17, - title: "Fix search", - url: "https://github.com/acme/repo/pull/17", - state: "open", - body: null, - labels: ["bug"], - baseRefName: "main", - headRefName: "fix-search", - }, - ]); - }); - - it("interprets modern search payloads at the query boundary", async () => { - const query = buildForgeSearchQueryOptions({ - client: { - async searchForge() { - return { - items: [ - { - kind: "issue", - number: 23, - title: "Keep this", - url: "https://gitlab.com/acme/repo/-/issues/23", - state: "open", - body: null, - labels: [], - }, - { kind: "future_kind", futureField: true }, - ], - authState: "future_auth_state", - error: null, - requestId: "forge-request", - }; - }, - }, - serverId: "server-1", - cwd: "/repo", - query: "23", - enabled: true, - supportsForgeSearch: true, - }); - - const result = await query.queryFn(); - - expect(result.items).toHaveLength(1); - expect(result.items[0]?.kind).toBe("issue"); - expect(result.authState).toBe("unauthenticated"); - }); - - it("derives legacy search auth from legacy feature flags", async () => { - const query = buildForgeSearchQueryOptions({ - client: { - async searchForge() { - throw new Error("unexpected forge search"); - }, - async searchGitHub() { - return { - items: [], - githubFeaturesEnabled: false, - error: null, - requestId: "github-request", - }; - }, - }, - serverId: "server-1", - cwd: "/repo", - query: "23", - enabled: true, - supportsForgeSearch: false, - }); - - expect((await query.queryFn()).authState).toBe("unauthenticated"); - }); - - it("invokes forge search bound to the client so this-dependent methods work", async () => { - const client = new ThisDependentSearchClient(); - - const query = buildForgeSearchQueryOptions({ - client, - serverId: "server-1", - cwd: "/repo", - query: " 789 ", - enabled: true, + it("searches through the already-bound workspace capability", async () => { + const searchForge = vi.fn(async () => ({ + items: [], + authState: "authenticated" as const, + error: null, + requestId: "search-1", + })); + const options = buildForgeSearchQueryOptions({ + client: { ...target, searchForge }, + serverId: "server", + query: " issue ", supportsForgeSearch: true, - }); - - const result = await query.queryFn(); - - expect(result.requestId).toBe("forge.search.request"); - expect(client.requests).toEqual([{ cwd: "/repo", query: "789", limit: 20 }]); - }); - - it("invokes the legacy GitHub search bound to the client", async () => { - const client = new ThisDependentSearchClient(); - - const query = buildForgeSearchQueryOptions({ - client, - serverId: "server-1", - cwd: "/repo", - query: " 789 ", enabled: true, - supportsForgeSearch: false, }); - - const result = await query.queryFn(); - - expect(result.requestId).toBe("github_search_request"); - expect(client.requests).toEqual([{ cwd: "/repo", query: "789", limit: 20 }]); + await expect(options.queryFn()).resolves.toMatchObject({ authState: "authenticated" }); + expect(searchForge).toHaveBeenCalledWith({ query: "issue", limit: 20 }); }); -}); - -class ThisDependentSearchClient { - readonly requests: unknown[] = []; - private send(requestId: string, options: { cwd: string; query: string; limit?: number }) { - this.requests.push(options); - return Promise.resolve({ + it("uses the bound legacy GitHub method when the forge RPC is unavailable", async () => { + const searchForge = vi.fn(); + const searchGitHub = vi.fn(async () => ({ items: [], - featuresEnabled: true, authState: "authenticated" as const, + featuresEnabled: true, githubFeaturesEnabled: true, error: null, - requestId, + requestId: "search-legacy", + })); + const options = buildForgeSearchQueryOptions({ + client: { ...target, searchForge, searchGitHub }, + serverId: "server", + query: "42", + kinds: ["change_request"], + enabled: true, }); - } - - async searchForge(options: { cwd: string; query: string; limit?: number }) { - return this.send("forge.search.request", options); - } - - async searchGitHub(options: { cwd: string; query: string; limit?: number }) { - return this.send("github_search_request", options); - } -} + await options.queryFn(); + expect(searchGitHub).toHaveBeenCalledWith({ query: "42", limit: 20, kinds: ["github-pr"] }); + expect(searchForge).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/git/use-forge-search-query.ts b/packages/app/src/git/use-forge-search-query.ts index 747dd033f8..7a6b6923b0 100644 --- a/packages/app/src/git/use-forge-search-query.ts +++ b/packages/app/src/git/use-forge-search-query.ts @@ -11,6 +11,8 @@ import { import { i18n } from "@/i18n/i18next"; import { useFetchQuery } from "@/data/query"; import { parseForgeAuthState } from "@/git/forge"; +import type { WorkspaceGitClient, WorkspaceGitReadClient } from "@/git/workspace-git"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; export const FORGE_SEARCH_STALE_TIME = 30_000; @@ -22,36 +24,27 @@ export interface ForgeSearchPayload { } interface ForgeSearchOptions { - cwd: string; query: string; limit?: number; kinds?: ForgeSearchKind[]; } interface LegacyGitHubSearchOptions { - cwd: string; query: string; limit?: number; kinds?: LegacyGitHubSearchKind[]; } -export interface ForgeSearchClient { - searchForge: ( - options: ForgeSearchOptions, - requestId?: string, - ) => Promise; - searchGitHub?: ( - options: LegacyGitHubSearchOptions, - requestId?: string, - ) => Promise; -} +type ForgeSearchTarget = Pick; +export type ForgeSearchClient = ForgeSearchTarget & + Pick & + Partial>; type LegacyGitHubSearchKind = "github-issue" | "github-pr"; interface ForgeSearchQueryInput { client: ForgeSearchClient | null; serverId: string; - cwd: string; query: string; kinds?: ForgeSearchKind[]; enabled: boolean; @@ -59,42 +52,53 @@ interface ForgeSearchQueryInput { hostDisconnectedMessage?: string; } +interface LegacyForgeSearchQueryInput extends Omit { + client: Pick | null; + cwd: string; +} + export function forgeSearchQueryKey( serverId: string, - cwd: string, + target: ForgeSearchTarget, query: string, kinds?: ForgeSearchKind[], transport: "forge" | "github" = "forge", ) { const trimmedQuery = query.trim(); + const queryIdentity = forgeSearchClientIdentity(target); if (!kinds) { - return ["forge-search", serverId, cwd, transport, trimmedQuery] as const; + return ["forge-search", serverId, queryIdentity, target.cwd, transport, trimmedQuery] as const; } return [ "forge-search", serverId, - cwd, + queryIdentity, + target.cwd, transport, trimmedQuery, [...kinds].sort().join(","), ] as const; } +export function forgeSearchClientIdentity(client: ForgeSearchTarget) { + return client.queryIdentity; +} + export function buildForgeSearchQueryOptions(input: ForgeSearchQueryInput) { const query = input.query.trim(); const transport = input.supportsForgeSearch === true ? "forge" : "github"; return { - queryKey: forgeSearchQueryKey(input.serverId, input.cwd, query, input.kinds, transport), + queryKey: input.client + ? forgeSearchQueryKey(input.serverId, input.client, query, input.kinds, transport) + : (["forge-search", input.serverId, "unbound", transport, query] as const), queryFn: async (): Promise => { if (!input.client) { throw new Error( input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"), ); } - const request = input.kinds - ? { cwd: input.cwd, query, limit: 20, kinds: input.kinds } - : { cwd: input.cwd, query, limit: 20 }; + const request = input.kinds ? { query, limit: 20, kinds: input.kinds } : { query, limit: 20 }; // COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 once // clients use forge.search.*. if (transport === "github" && input.client.searchGitHub) { @@ -153,7 +157,7 @@ function toLegacyGitHubSearchKind(kind: ForgeSearchKind): LegacyGitHubSearchKind function toLegacyGitHubSearchRequest(request: ForgeSearchOptions): LegacyGitHubSearchOptions { if (!request.kinds) { - return { cwd: request.cwd, query: request.query, limit: request.limit }; + return { query: request.query, limit: request.limit }; } return { ...request, @@ -161,6 +165,48 @@ function toLegacyGitHubSearchRequest(request: ForgeSearchOptions): LegacyGitHubS }; } +export function buildLegacyForgeSearchQueryOptions(input: LegacyForgeSearchQueryInput) { + const query = input.query.trim(); + const transport = input.supportsForgeSearch === true ? "forge" : "github"; + return { + queryKey: ["legacy-forge-search", input.serverId, input.cwd, transport, query] as const, + queryFn: async (): Promise => { + if (!input.client) { + throw new Error( + input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"), + ); + } + const request = input.kinds + ? { cwd: input.cwd, query, limit: 20, kinds: input.kinds } + : { cwd: input.cwd, query, limit: 20 }; + if (transport === "github") { + return normalizeLegacyGitHubSearchPayload( + await input.client.searchGitHub({ + cwd: input.cwd, + query, + limit: 20, + kinds: input.kinds?.map(toLegacyGitHubSearchKind), + }), + ); + } + return normalizeForgeSearchPayload(await input.client.searchForge(request)); + }, + enabled: input.enabled && Boolean(input.client), + dataShape: "list" as const, + staleTimeMs: FORGE_SEARCH_STALE_TIME, + }; +} + +export function useLegacyForgeSearchQuery(input: LegacyForgeSearchQueryInput) { + const { t } = useTranslation(); + return useFetchQuery( + buildLegacyForgeSearchQueryOptions({ + ...input, + hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), + }), + ); +} + export function useForgeSearchQuery(input: ForgeSearchQueryInput) { const { t } = useTranslation(); return useFetchQuery( diff --git a/packages/app/src/git/use-pr-status-query.ts b/packages/app/src/git/use-pr-status-query.ts index e8e4bf2b14..f1256bc771 100644 --- a/packages/app/src/git/use-pr-status-query.ts +++ b/packages/app/src/git/use-pr-status-query.ts @@ -1,14 +1,14 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { checkoutPrStatusQueryKey } from "@/git/query-keys"; import { normalizeForge } from "@/git/forge"; import { selectPrHintFromStatus, type PrHint } from "@/git/pr-hint"; import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status"; +import { useWorkspaceGit } from "@/git/workspace-git"; interface UseCheckoutPrStatusQueryOptions { serverId: string; - cwd: string; enabled?: boolean; } @@ -21,22 +21,23 @@ function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null export function useCheckoutPrStatusQuery({ serverId, - cwd, enabled = true, }: UseCheckoutPrStatusQueryOptions) { const { t } = useTranslation(); - const client = useHostRuntimeClient(serverId); + const workspaceGit = useWorkspaceGit(); const isConnected = useHostRuntimeIsConnected(serverId); const query = useQuery({ - queryKey: checkoutPrStatusQueryKey(serverId, cwd), + queryKey: workspaceGit + ? checkoutPrStatusQueryKey(serverId, workspaceGit) + : (["checkoutPrStatus", serverId, "unavailable"] as const), queryFn: async () => { - if (!client) { + if (!workspaceGit) { throw new Error(t("common.errors.daemonClientUnavailable")); } - return normalizeCheckoutPrStatusPayload(await client.checkoutPrStatus(cwd)); + return normalizeCheckoutPrStatusPayload(await workspaceGit.getPrStatus()); }, - enabled: !!client && isConnected && !!cwd && enabled, + enabled: isConnected && enabled && workspaceGit !== null, staleTime: Infinity, // Refetch on mount only after explicit invalidation (e.g. reconnect) — see // useCheckoutStatusQuery for the rationale. @@ -63,22 +64,23 @@ export function useCheckoutPrStatusQuery({ export function useWorkspacePrHint({ serverId, - cwd, enabled = true, }: UseCheckoutPrStatusQueryOptions): PrHint | null { const { t } = useTranslation(); - const client = useHostRuntimeClient(serverId); + const workspaceGit = useWorkspaceGit(); const isConnected = useHostRuntimeIsConnected(serverId); const query = useQuery({ - queryKey: checkoutPrStatusQueryKey(serverId, cwd), + queryKey: workspaceGit + ? checkoutPrStatusQueryKey(serverId, workspaceGit) + : (["checkoutPrStatus", serverId, "unavailable"] as const), queryFn: async () => { - if (!client) { + if (!workspaceGit) { throw new Error(t("common.errors.daemonClientUnavailable")); } - return normalizeCheckoutPrStatusPayload(await client.checkoutPrStatus(cwd)); + return normalizeCheckoutPrStatusPayload(await workspaceGit.getPrStatus()); }, - enabled: !!client && isConnected && !!cwd && enabled, + enabled: isConnected && enabled && workspaceGit !== null, staleTime: Infinity, // Refetch on mount only after explicit invalidation (e.g. reconnect) — see // useCheckoutStatusQuery for the rationale. diff --git a/packages/app/src/git/use-status-query.ts b/packages/app/src/git/use-status-query.ts index 0cc0d7f93c..ac84e96da0 100644 --- a/packages/app/src/git/use-status-query.ts +++ b/packages/app/src/git/use-status-query.ts @@ -1,8 +1,9 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { checkoutStatusQueryKey } from "@/git/query-keys"; +import { checkoutStatusQueryKey, legacyCheckoutStatusQueryKey } from "@/git/query-keys"; import { fetchCheckoutStatus } from "./checkout-status-cache"; +import { useWorkspaceGit } from "./workspace-git"; export type { CheckoutStatusPayload } from "./checkout-status-cache"; @@ -10,23 +11,25 @@ export const CHECKOUT_STATUS_STALE_TIME = 15_000; interface UseCheckoutStatusQueryOptions { serverId: string; - cwd: string; } -export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) { - const { t } = useTranslation(); - const client = useHostRuntimeClient(serverId); +export function useCheckoutStatusQuery({ serverId }: UseCheckoutStatusQueryOptions) { + const workspaceGit = useWorkspaceGit(); const isConnected = useHostRuntimeIsConnected(serverId); const query = useQuery({ - queryKey: checkoutStatusQueryKey(serverId, cwd), + queryKey: workspaceGit + ? checkoutStatusQueryKey(serverId, workspaceGit) + : (["checkoutStatus", serverId, "unavailable"] as const), queryFn: async () => { - if (!client) { - throw new Error(t("common.errors.daemonClientUnavailable")); - } - return await fetchCheckoutStatus({ client, serverId, cwd }); + if (!workspaceGit) throw new Error("Workspace Git is unavailable"); + return await fetchCheckoutStatus({ + client: workspaceGit, + serverId, + target: workspaceGit, + }); }, - enabled: !!client && isConnected && !!cwd, + enabled: isConnected && workspaceGit !== null, staleTime: Infinity, // Freshness is push-driven (checkout_status_update applied globally); with // staleTime: Infinity, refetchOnMount only fires after an explicit invalidation @@ -50,19 +53,44 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery * initiating a fetch. Useful for list rows where a parent component prefetches * only the visible agents. */ -export function useCheckoutStatusCacheOnly({ serverId, cwd }: UseCheckoutStatusQueryOptions) { - const { t } = useTranslation(); - const client = useHostRuntimeClient(serverId); +export function useCheckoutStatusCacheOnly({ serverId }: UseCheckoutStatusQueryOptions) { + const workspaceGit = useWorkspaceGit(); return useQuery({ - queryKey: checkoutStatusQueryKey(serverId, cwd), + queryKey: workspaceGit + ? checkoutStatusQueryKey(serverId, workspaceGit) + : (["checkoutStatus", serverId, "unavailable"] as const), queryFn: async () => { - if (!client) { - throw new Error(t("common.errors.daemonClientUnavailable")); - } - return await fetchCheckoutStatus({ client, serverId, cwd }); + if (!workspaceGit) throw new Error("Workspace Git is unavailable"); + return await fetchCheckoutStatus({ + client: workspaceGit, + serverId, + target: workspaceGit, + }); }, enabled: false, staleTime: CHECKOUT_STATUS_STALE_TIME, }); } + +export function useLegacyCheckoutStatusQuery({ serverId, cwd }: { serverId: string; cwd: string }) { + const { t } = useTranslation(); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + const query = useQuery({ + queryKey: legacyCheckoutStatusQueryKey(serverId, cwd), + queryFn: async () => { + if (!client) throw new Error(t("common.errors.daemonClientUnavailable")); + return await client.getCheckoutStatus(cwd); + }, + enabled: !!client && isConnected && !!cwd, + staleTime: Infinity, + }); + return { + status: query.data ?? null, + isLoading: query.isLoading, + isFetching: query.isFetching, + isError: query.isError, + error: query.error, + }; +} diff --git a/packages/app/src/git/use-working-diff.ts b/packages/app/src/git/use-working-diff.ts index 494a0f95a2..cf6f99896c 100644 --- a/packages/app/src/git/use-working-diff.ts +++ b/packages/app/src/git/use-working-diff.ts @@ -16,7 +16,7 @@ import { useCheckoutStatusQuery } from "@/git/use-status-query"; interface UseWorkingDiffOptions { serverId: string; - workspaceId?: string; + workspaceId: string; cwd: string; ignoreWhitespace: boolean; enabled: boolean; @@ -36,7 +36,7 @@ export function useWorkingDiff({ isLoading: isStatusLoading, isError: isStatusError, error: statusError, - } = useCheckoutStatusQuery({ serverId, cwd }); + } = useCheckoutStatusQuery({ serverId }); const gitStatus = status && status.isGit ? status : null; const isGit = Boolean(gitStatus); const notGit = status !== null && !status.isGit && !status.error; @@ -83,7 +83,6 @@ export function useWorkingDiff({ isLoading: isDiffLoading, } = useCheckoutDiffQuery({ serverId, - cwd, mode: diffMode, baseRef, ignoreWhitespace, diff --git a/packages/app/src/git/workspace-actions.tsx b/packages/app/src/git/workspace-actions.tsx index f5241b19a2..8e497f6131 100644 --- a/packages/app/src/git/workspace-actions.tsx +++ b/packages/app/src/git/workspace-actions.tsx @@ -4,11 +4,13 @@ import { GIT_ACTION_ICONS } from "@/git/action-icons"; interface WorkspaceActionsProps { serverId: string; + workspaceId: string; cwd: string; + isWorkspaceGitBound: boolean; hideLabels?: boolean; } -export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) { +function BoundWorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) { const { gitActions } = useGitActions({ serverId, cwd, @@ -17,3 +19,7 @@ export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActions return ; } + +export function WorkspaceActions(props: WorkspaceActionsProps) { + return props.isWorkspaceGitBound ? : null; +} diff --git a/packages/app/src/git/workspace-git-owner.browser.test.tsx b/packages/app/src/git/workspace-git-owner.browser.test.tsx new file mode 100644 index 0000000000..9d5a3d0fdd --- /dev/null +++ b/packages/app/src/git/workspace-git-owner.browser.test.tsx @@ -0,0 +1,79 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React, { useCallback } from "react"; +import { afterEach, expect, test } from "vitest"; +import { PreWorkspaceComposerGitOwner, useWorkspaceGit } from "./workspace-git"; + +afterEach(cleanup); + +function createLegacyGitPort() { + const statusCwds: string[] = []; + return { + client: { + async getCheckoutStatus(cwd: string) { + statusCwds.push(cwd); + return { cwd }; + }, + async checkoutPrStatus(cwd: string) { + return { cwd }; + }, + async searchForge() { + return { items: [], authState: "unknown", error: null }; + }, + async searchGitHub() { + return { items: [], error: null }; + }, + }, + statusCwds, + }; +} + +function SharedComposerGitConsumer() { + const workspaceGit = useWorkspaceGit(); + const fetchStatus = useCallback(() => { + void workspaceGit?.getStatus(); + }, [workspaceGit]); + return ( + + ); +} + +test.each(["terminal", "chat"])( + "the new-workspace %s Composer production owner supplies its legacy cwd", + async () => { + const port = createLegacyGitPort(); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "/draft/source" })); + await waitFor(() => expect(port.statusCwds).toEqual(["/draft/source"])); + }, +); + +test("the new-workspace Composer production owner represents no cwd without crashing", () => { + const port = createLegacyGitPort(); + render( + + + , + ); + + expect(screen.getByRole("button", { name: "Git unavailable" })).toBeVisible(); + expect(port.statusCwds).toEqual([]); +}); + +test("the workspace-setup Composer production owner supplies its source directory", async () => { + const port = createLegacyGitPort(); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "/setup/source" })); + await waitFor(() => expect(port.statusCwds).toEqual(["/setup/source"])); +}); diff --git a/packages/app/src/git/workspace-git.test.ts b/packages/app/src/git/workspace-git.test.ts new file mode 100644 index 0000000000..62cd1145bc --- /dev/null +++ b/packages/app/src/git/workspace-git.test.ts @@ -0,0 +1,71 @@ +import { expect, test, vi } from "vitest"; +import { bindLegacyWorkspaceGit, bindSelectedWorkspaceGit } from "./workspace-git"; + +test.each(["", " "])( + "the app selected Git boundary rejects workspaceId %j before binding a daemon client", + (workspaceId) => { + const bindWorkspaceGit = vi.fn(); + + expect(() => + bindSelectedWorkspaceGit( + { bindWorkspaceGit }, + { kind: "selected", workspaceId, cwd: "/shared" }, + ), + ).toThrow("workspaceId is required for selected workspace Git"); + expect(bindWorkspaceGit).not.toHaveBeenCalled(); + }, +); + +test.each(["", " "])( + "the app selected Git boundary rejects cwd %j before binding a daemon client", + (cwd) => { + const bindWorkspaceGit = vi.fn(); + + expect(() => + bindSelectedWorkspaceGit( + { bindWorkspaceGit }, + { kind: "selected", workspaceId: "workspace-a", cwd }, + ), + ).toThrow("cwd is required for selected workspace Git"); + expect(bindWorkspaceGit).not.toHaveBeenCalled(); + }, +); + +test("the app selected Git boundary normalizes its complete address once", () => { + const bindWorkspaceGit = vi.fn(() => ({ workspaceId: "workspace-a", cwd: "/shared" })); + + const workspaceGit = bindSelectedWorkspaceGit({ bindWorkspaceGit } as never, { + kind: "selected", + workspaceId: " workspace-a ", + cwd: " /shared ", + }); + + expect(bindWorkspaceGit).toHaveBeenCalledWith({ workspaceId: "workspace-a", cwd: "/shared" }); + expect(workspaceGit).toMatchObject({ + workspaceId: "workspace-a", + cwd: "/shared", + queryIdentity: ["selected", "workspace-a"], + }); +}); + +test("selected and legacy capabilities own structurally distinct query identities", () => { + const selected = bindSelectedWorkspaceGit( + { + bindWorkspaceGit: () => ({ workspaceId: "legacy:/shared", cwd: "/shared" }), + } as never, + { kind: "selected", workspaceId: "legacy:/shared", cwd: "/shared" }, + ); + const legacy = bindLegacyWorkspaceGit( + { + checkoutPrStatus: vi.fn(), + getCheckoutStatus: vi.fn(), + searchForge: vi.fn(), + searchGitHub: vi.fn(), + }, + { kind: "legacy", cwd: "/shared" }, + ); + + expect(selected.queryIdentity).toEqual(["selected", "legacy:/shared"]); + expect(legacy.queryIdentity).toEqual(["legacy", "/shared"]); + expect(selected.queryIdentity).not.toEqual(legacy.queryIdentity); +}); diff --git a/packages/app/src/git/workspace-git.ts b/packages/app/src/git/workspace-git.ts new file mode 100644 index 0000000000..0ec9304165 --- /dev/null +++ b/packages/app/src/git/workspace-git.ts @@ -0,0 +1,168 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { createContext, createElement, type ReactNode, useContext, useMemo } from "react"; + +export interface SelectedWorkspaceGitAddress { + readonly kind: "selected"; + workspaceId: string; + cwd: string; +} + +export interface LegacyWorkspaceGitAddress { + readonly kind: "legacy"; + cwd: string; +} + +export type WorkspaceGitQueryIdentity = + | readonly [kind: "selected", workspaceId: string] + | readonly [kind: "legacy", cwd: string]; + +export function workspaceGitQueryIdentitiesEqual( + left: WorkspaceGitQueryIdentity, + right: WorkspaceGitQueryIdentity, +): boolean { + return left[0] === right[0] && left[1] === right[1]; +} + +type WorkspaceGitBinder = Pick; +type LegacyWorkspaceGitBinder = Pick< + DaemonClient, + "checkoutPrStatus" | "getCheckoutStatus" | "searchForge" | "searchGitHub" +>; + +export function bindSelectedWorkspaceGit( + client: WorkspaceGitBinder, + address: SelectedWorkspaceGitAddress, +) { + const workspaceId = address.workspaceId.trim(); + if (workspaceId.length === 0) { + throw new Error("workspaceId is required for selected workspace Git"); + } + const cwd = address.cwd.trim(); + if (cwd.length === 0) { + throw new Error("cwd is required for selected workspace Git"); + } + return Object.assign(client.bindWorkspaceGit({ workspaceId, cwd }), { + kind: "selected" as const, + workspaceId, + cwd, + queryIdentity: ["selected", workspaceId] as const, + }); +} + +export type WorkspaceGitClient = ReturnType; +export type WorkspaceGitReadClient = Readonly< + Pick & { + queryIdentity: WorkspaceGitQueryIdentity; + } +>; +export type WorkspaceGitTarget = Readonly< + Pick +>; +export type WorkspaceGitStatusTarget = Readonly< + Pick +>; + +export function bindLegacyWorkspaceGit( + client: LegacyWorkspaceGitBinder, + address: LegacyWorkspaceGitAddress, +): WorkspaceGitReadClient & { readonly kind: "legacy" } { + const cwd = address.cwd.trim(); + if (cwd.length === 0) { + throw new Error("cwd is required for legacy workspace Git"); + } + return { + kind: "legacy", + cwd, + queryIdentity: ["legacy", cwd], + getPrStatus: () => client.checkoutPrStatus(cwd), + getStatus: () => client.getCheckoutStatus(cwd), + searchForge: (options, requestId) => client.searchForge({ cwd, ...options }, requestId), + searchGitHub: (options, requestId) => client.searchGitHub({ cwd, ...options }, requestId), + }; +} + +export function PreWorkspaceComposerGitOwner({ + client, + cwd, + children, +}: { + client: (WorkspaceGitBinder & LegacyWorkspaceGitBinder) | null; + cwd: string | null; + children?: ReactNode; +}) { + const address = useMemo(() => (cwd === null ? null : ({ kind: "legacy", cwd } as const)), [cwd]); + return createElement(WorkspaceGitOwner, { client, address }, children); +} + +type LegacyWorkspaceGitClient = ReturnType; +type WorkspaceGitCapability = WorkspaceGitClient | LegacyWorkspaceGitClient | null; +const MISSING_WORKSPACE_GIT_OWNER = Symbol("missing-workspace-git-owner"); +const WorkspaceGitContext = createContext< + WorkspaceGitCapability | typeof MISSING_WORKSPACE_GIT_OWNER +>(MISSING_WORKSPACE_GIT_OWNER); + +export function useBoundWorkspaceGit( + client: (WorkspaceGitBinder & LegacyWorkspaceGitBinder) | null, + address: SelectedWorkspaceGitAddress | null, +): WorkspaceGitClient | null; +export function useBoundWorkspaceGit( + client: (WorkspaceGitBinder & LegacyWorkspaceGitBinder) | null, + address: LegacyWorkspaceGitAddress | null, +): LegacyWorkspaceGitClient | null; +export function useBoundWorkspaceGit( + client: (WorkspaceGitBinder & LegacyWorkspaceGitBinder) | null, + address: SelectedWorkspaceGitAddress | LegacyWorkspaceGitAddress | null, +): WorkspaceGitCapability; +export function useBoundWorkspaceGit( + client: (WorkspaceGitBinder & LegacyWorkspaceGitBinder) | null, + address: SelectedWorkspaceGitAddress | LegacyWorkspaceGitAddress | null, +): WorkspaceGitCapability { + return useMemo(() => { + if (!client || !address) return null; + return address.kind === "selected" + ? bindSelectedWorkspaceGit(client, address) + : bindLegacyWorkspaceGit(client, address); + }, [address, client]); +} + +export function WorkspaceGitBoundary({ + workspaceGit, + children, +}: { + workspaceGit: WorkspaceGitCapability; + children?: ReactNode; +}) { + return createElement(WorkspaceGitContext.Provider, { value: workspaceGit }, children); +} + +export function WorkspaceGitOwner({ + client, + address, + children, +}: { + client: (WorkspaceGitBinder & LegacyWorkspaceGitBinder) | null; + address: SelectedWorkspaceGitAddress | LegacyWorkspaceGitAddress | null; + children?: ReactNode; +}) { + const workspaceGit = useBoundWorkspaceGit(client, address); + return createElement(WorkspaceGitBoundary, { workspaceGit }, children); +} + +export function useWorkspaceGit(): WorkspaceGitReadClient | null { + const workspaceGit = useContext(WorkspaceGitContext); + if (workspaceGit === MISSING_WORKSPACE_GIT_OWNER) { + throw new Error("Workspace Git owner is missing"); + } + return workspaceGit; +} + +export function useRequiredWorkspaceGit(): WorkspaceGitClient { + const workspaceGit = useContext(WorkspaceGitContext); + if (workspaceGit === MISSING_WORKSPACE_GIT_OWNER) { + throw new Error("Workspace Git owner is missing"); + } + if (!workspaceGit || workspaceGit.kind !== "selected") { + throw new Error("Selected workspace Git is not bound"); + } + return workspaceGit; +} diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index b31129cea7..841a4b9f72 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -25,6 +25,7 @@ export interface SidebarWorkspacePlacement { projectName: string; projectRootPath?: string; workspaceDirectory?: string; + hostVisiblePath?: string; projectKind: WorkspaceStructureProject["projectKind"]; workspaceKind: WorkspaceDescriptor["workspaceKind"]; name: string; @@ -159,6 +160,7 @@ export function createSidebarWorkspaceEntry(input: { projectName: projectNameForWorkspace(input.workspace), projectRootPath: input.workspace.projectRootPath, workspaceDirectory: input.workspace.workspaceDirectory, + hostVisiblePath: input.workspace.hostVisiblePath, workspaceDirectoryLabel: input.workspace.worktreeSlug ?? shortenPath(input.workspace.workspaceDirectory), projectKind: input.workspace.projectKind, diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 456ce249c2..4266fec8f1 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -43,6 +43,8 @@ import type { MaterializedAgentProfile } from "@/agent-profiles"; export type { FormInitialValues } from "@/provider-selection/resolve-agent-form"; export interface UseAgentFormStateOptions { + workspaceId?: string | null; + providerSnapshotCwd?: string | null; initialServerId?: string | null; initialValues?: FormInitialValues; isVisible?: boolean; @@ -207,7 +209,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg initialValues, isVisible = true, isCreateFlow = true, - isTargetDaemonReady: _isTargetDaemonReady = true, + isTargetDaemonReady = true, onlineServerIds = [], } = options; @@ -269,7 +271,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg error: snapshotError, refresh: refreshSnapshot, refetchIfStale: refetchSnapshotIfStale, - } = useProvidersSnapshot(formState.serverId, { cwd: formState.workingDir }); + } = useProvidersSnapshot(formState.serverId, { + enabled: isTargetDaemonReady, + cwd: + options.providerSnapshotCwd === undefined + ? formState.workingDir + : options.providerSnapshotCwd, + workspaceId: options.workspaceId, + }); const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]); const snapshotProviderDefinitions = useMemo( diff --git a/packages/app/src/hooks/use-branch-switcher.ts b/packages/app/src/hooks/use-branch-switcher.ts index e8e33b7f25..a73f6a793e 100644 --- a/packages/app/src/hooks/use-branch-switcher.ts +++ b/packages/app/src/hooks/use-branch-switcher.ts @@ -1,18 +1,15 @@ import { useState, useCallback, useMemo } from "react"; import { useQuery, type QueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ComboboxOption } from "@/components/ui/combobox"; import type { ToastApi } from "@/components/toast-host"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { createBranchSwitcherOperations } from "@/git/branch-switcher-operations"; import { confirmDialog } from "@/utils/confirm-dialog"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; interface UseBranchSwitcherInput { - client: DaemonClient | null; normalizedServerId: string; - normalizedWorkspaceId: string; - workspaceDirectory: string | null; currentBranchName: string | null; isGitCheckout: boolean; isConnected: boolean; @@ -29,10 +26,7 @@ interface UseBranchSwitcherResult { } export function useBranchSwitcher({ - client, normalizedServerId, - normalizedWorkspaceId, - workspaceDirectory, currentBranchName, isGitCheckout, isConnected, @@ -40,20 +34,15 @@ export function useBranchSwitcher({ queryClient, }: UseBranchSwitcherInput): UseBranchSwitcherResult { const { t } = useTranslation(); + const workspaceGit = useRequiredWorkspaceGit(); const [isOpen, setIsOpen] = useState(false); // Git operations are bound to the workspace directory; the opaque workspace id is // used only for query cache identity below, never as a cwd. - const operations = useMemo( - () => - client && workspaceDirectory - ? createBranchSwitcherOperations(client, workspaceDirectory) - : null, - [client, workspaceDirectory], - ); + const operations = useMemo(() => createBranchSwitcherOperations(workspaceGit), [workspaceGit]); const branchSuggestionsQuery = useQuery({ - queryKey: ["branchSuggestions", normalizedServerId, normalizedWorkspaceId], + queryKey: ["branchSuggestions", normalizedServerId, workspaceGit.workspaceId], queryFn: async () => { if (!operations) { throw new Error(t("common.errors.daemonClientUnavailable")); @@ -75,20 +64,19 @@ export function useBranchSwitcher({ }, [branchSuggestionsQuery.data]); const stashListQueryKey = useMemo( - () => ["stashList", normalizedServerId, normalizedWorkspaceId] as const, - [normalizedServerId, normalizedWorkspaceId], + () => ["stashList", normalizedServerId, workspaceGit.workspaceId] as const, + [normalizedServerId, workspaceGit.workspaceId], ); const invalidateStashAndCheckout = useCallback(async () => { - if (!workspaceDirectory) return; await Promise.all([ queryClient.invalidateQueries({ queryKey: stashListQueryKey }), invalidateCheckoutGitQueriesForClient(queryClient, { serverId: normalizedServerId, - cwd: workspaceDirectory, + target: workspaceGit, }), ]); - }, [queryClient, stashListQueryKey, normalizedServerId, workspaceDirectory]); + }, [queryClient, stashListQueryKey, normalizedServerId, workspaceGit]); const maybeRestoreStashForBranch = useCallback( async (branchId: string) => { diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index 53714e37b3..e39d5e56a8 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -138,7 +138,12 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor } try { - const directory = await client.listDirectory(normalizedWorkspaceRoot, normalizedPath); + const directory = await client.listDirectory( + normalizedWorkspaceRoot, + normalizedPath, + undefined, + workspaceId ?? undefined, + ); updateExplorerState((state) => { const nextState: AgentFileExplorerState = { ...state, @@ -169,7 +174,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor return null; } }, - [client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey], + [client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceId, workspaceStateKey], ); const requestFilePreview = useCallback( @@ -206,7 +211,12 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor } try { - const file = await client.readFile(normalizedWorkspaceRoot, normalizedPath); + const file = await client.readFile( + normalizedWorkspaceRoot, + normalizedPath, + undefined, + workspaceId ?? undefined, + ); updateExplorerState((state) => { const nextState: AgentFileExplorerState = { ...state, @@ -233,7 +243,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor })); } }, - [client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey], + [client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceId, workspaceStateKey], ); const requestFileDownloadToken = useCallback( @@ -244,13 +254,18 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor if (!client) { throw new Error(t("workspace.terminal.hostDisconnected")); } - const payload = await client.requestDownloadToken(normalizedWorkspaceRoot, path); + const payload = await client.requestDownloadToken( + normalizedWorkspaceRoot, + path, + undefined, + workspaceId ?? undefined, + ); if (payload.error) { throw new Error(payload.error); } return payload; }, - [client, normalizedWorkspaceRoot, t], + [client, normalizedWorkspaceRoot, t, workspaceId], ); const createEntry = useCallback( diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index 620668e1fb..c040a3e205 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -185,7 +185,7 @@ describe("fetchProvidersSnapshot", () => { expect(cache.writes).toEqual([ { serverId, - cwd: "/repo-a", + scope: { type: "legacy-cwd", cwd: "/repo-a" }, hash: "next-hash", generatedAt: "2026-01-02T00:00:00.000Z", compactSnapshot, @@ -369,7 +369,7 @@ describe("applyProvidersSnapshotUpdate", () => { expect(cache.writes).toEqual([ { serverId, - cwd: "/repo-a", + scope: { type: "legacy-cwd", cwd: "/repo-a" }, hash: "push-hash", generatedAt: "2026-01-01T00:00:01.000Z", compactSnapshot, diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index 9dfdae6f37..81ff37eaa7 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -21,6 +21,7 @@ import { providersSnapshotQueryRoot, providersSnapshotRequestOptions, } from "@/data/providers-snapshot"; +import type { ProviderSnapshotCacheScope } from "@/data/provider-snapshot-cache"; type GetProvidersSnapshotResult = Awaited>; type RefreshProvidersSnapshotResult = Awaited>; @@ -32,16 +33,29 @@ export type ProvidersSnapshotClient = Pick< "getProvidersSnapshot" | "refreshProvidersSnapshot" >; +function providerSnapshotCacheScope( + cwd: string | null, + workspaceId?: string | null, +): ProviderSnapshotCacheScope { + return workspaceId ? { type: "workspace", workspaceId } : { type: "legacy-cwd", cwd }; +} + export async function fetchProvidersSnapshot(input: { client: ProvidersSnapshotClient; serverId: string; cwd: string | null; + workspaceId?: string | null; cache?: ProviderSnapshotCache; }): Promise { const cache = input.cache ?? providerSnapshotCache; - const cached = await cache.read(input.serverId, input.cwd); + const scope = providerSnapshotCacheScope(input.cwd, input.workspaceId); + const cached = await cache.read(input.serverId, scope); const snapshot = await input.client.getProvidersSnapshot( - providersSnapshotRequestOptions({ cwd: input.cwd, ifNoneMatch: cached?.hash }), + providersSnapshotRequestOptions({ + cwd: input.cwd, + workspaceId: input.workspaceId, + ifNoneMatch: cached?.hash, + }), ); if (snapshot.notModified) { if (!cached) { @@ -52,7 +66,7 @@ export async function fetchProvidersSnapshot(input: { if (snapshot.compactSnapshot && snapshot.snapshotHash) { await cache.write({ serverId: input.serverId, - cwd: input.cwd, + scope, hash: snapshot.snapshotHash, generatedAt: snapshot.generatedAt, compactSnapshot: snapshot.compactSnapshot, @@ -66,19 +80,28 @@ export async function refreshAndApplyProvidersSnapshot(input: { queryClient: QueryClient; serverId: string; cwd: string | null; + workspaceId?: string | null; providers?: AgentProvider[]; cache?: ProviderSnapshotCache; }): Promise { const refreshResult = await input.client.refreshProvidersSnapshot( - providersSnapshotRequestOptions({ cwd: input.cwd, providers: input.providers }), + providersSnapshotRequestOptions({ + cwd: input.cwd, + workspaceId: input.workspaceId, + providers: input.providers, + }), ); const snapshot = await fetchProvidersSnapshot({ client: input.client, serverId: input.serverId, cwd: input.cwd, + workspaceId: input.workspaceId, cache: input.cache, }); - input.queryClient.setQueryData(providersSnapshotQueryKey(input.serverId, input.cwd), snapshot); + input.queryClient.setQueryData( + providersSnapshotQueryKey(input.serverId, input.cwd, input.workspaceId), + snapshot, + ); void input.queryClient.invalidateQueries({ queryKey: agentCommandsQueryRoot(input.serverId), exact: false, @@ -122,6 +145,7 @@ interface UseProvidersSnapshotResult { interface UseProvidersSnapshotOptions { enabled?: boolean; cwd?: string | null; + workspaceId?: string | null; } export function useProvidersSnapshot( @@ -132,14 +156,18 @@ export function useProvidersSnapshot( const retainedPanelActive = useRetainedPanelActive(); const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId ?? ""); - const enabled = (options.enabled ?? true) && retainedPanelActive; + const targetEnabled = options.enabled ?? true; + const enabled = targetEnabled && retainedPanelActive; const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const cwd = normalizeProvidersSnapshotCwd(options.cwd); const supportsSnapshot = useSessionStore( (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true, ); - const queryKey = useMemo(() => providersSnapshotQueryKey(serverId, cwd), [cwd, serverId]); + const queryKey = useMemo( + () => providersSnapshotQueryKey(serverId, cwd, options.workspaceId), + [cwd, options.workspaceId, serverId], + ); const snapshotQuery = useReplicaQuery({ queryKey, @@ -149,7 +177,12 @@ export function useProvidersSnapshot( if (!client || !serverId) { throw new Error(t("workspace.terminal.hostDisconnected")); } - return fetchProvidersSnapshot({ client, serverId, cwd }); + return fetchProvidersSnapshot({ + client, + serverId, + cwd, + workspaceId: options.workspaceId, + }); }, }); @@ -163,6 +196,7 @@ export function useProvidersSnapshot( queryClient, serverId, cwd, + workspaceId: options.workspaceId, providers, }); }, @@ -192,11 +226,12 @@ export function useProvidersSnapshot( ); return { - entries: snapshotQuery.data?.entries ?? undefined, - isLoading: snapshotQuery.isLoading, - isFetching: snapshotQuery.isFetching, - isRefreshing, - error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null, + entries: targetEnabled ? (snapshotQuery.data?.entries ?? undefined) : undefined, + isLoading: targetEnabled && snapshotQuery.isLoading, + isFetching: targetEnabled && snapshotQuery.isFetching, + isRefreshing: targetEnabled && isRefreshing, + error: + targetEnabled && snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null, supportsSnapshot, refresh, refetchIfStale, @@ -206,13 +241,14 @@ export function useProvidersSnapshot( export function prefetchProvidersSnapshot( serverId: string, client: DaemonClient, - options: { cwd?: string | null } = {}, + options: { cwd?: string | null; workspaceId?: string | null } = {}, ): void { const cwd = normalizeProvidersSnapshotCwd(options.cwd); - const queryKey = providersSnapshotQueryKey(serverId, cwd); + const queryKey = providersSnapshotQueryKey(serverId, cwd, options.workspaceId); void singletonQueryClient.prefetchQuery({ queryKey, staleTime: Infinity, - queryFn: () => fetchProvidersSnapshot({ client, serverId, cwd }), + queryFn: () => + fetchProvidersSnapshot({ client, serverId, cwd, workspaceId: options.workspaceId }), }); } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 9236429686..98038fe27e 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1100,6 +1100,8 @@ export const ar: TranslationResources = { newWorkspace: { title: "مساحة عمل جديدة", create: "يخلق", + runtime: { local: "محلي", worktree: "شجرة العمل", label: "بيئة التشغيل" }, + runtimeProbe: { preparing: "جارٍ تجهيز البيئة" }, isolation: { local: "محلي", worktree: "شجرة عمل جديدة", @@ -1121,6 +1123,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 c12ab440de..50502c43ef 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1109,6 +1109,8 @@ export const en = { newWorkspace: { title: "New workspace", create: "Create", + runtime: { local: "Local", worktree: "Worktree", label: "Runtime" }, + runtimeProbe: { preparing: "Preparing environment" }, isolation: { local: "Local", worktree: "New worktree", @@ -1130,6 +1132,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 f28516005a..e88091bbc0 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1131,6 +1131,8 @@ export const es: TranslationResources = { newWorkspace: { title: "Nuevo espacio de trabajo", create: "Crear", + runtime: { local: "Local", worktree: "Worktree", label: "Entorno" }, + runtimeProbe: { preparing: "Preparando el entorno" }, isolation: { local: "Local", worktree: "Nuevo worktree", @@ -1152,6 +1154,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 0bda488b2a..5ba2a36a76 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1130,6 +1130,8 @@ export const fr: TranslationResources = { newWorkspace: { title: "Nouvel espace de travail", create: "Créer", + runtime: { local: "Local", worktree: "Worktree", label: "Environnement" }, + runtimeProbe: { preparing: "Préparation de l’environnement" }, isolation: { local: "Local", worktree: "Nouveau worktree", @@ -1151,6 +1153,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 c20a905be4..069cb1ff96 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1111,6 +1111,8 @@ export const ja: TranslationResources = { newWorkspace: { title: "新しいワークスペース", create: "作成", + runtime: { local: "ローカル", worktree: "ワークツリー", label: "ランタイム" }, + runtimeProbe: { preparing: "環境を準備しています" }, isolation: { local: "ローカル", worktree: "新しいワークツリー", @@ -1132,6 +1134,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 99f4278337..80d2c45bc5 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1107,6 +1107,8 @@ export const ko: TranslationResources = { newWorkspace: { title: "새 워크스페이스", create: "생성", + runtime: { local: "로컬", worktree: "워크트리", label: "런타임" }, + runtimeProbe: { preparing: "환경 준비 중" }, isolation: { local: "로컬", worktree: "새 워크트리", @@ -1128,6 +1130,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 f0a09e4d81..6a3a85dd5a 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1122,6 +1122,8 @@ export const ptBR: TranslationResources = { newWorkspace: { title: "Novo workspace", create: "Criar", + runtime: { local: "Local", worktree: "Worktree", label: "Ambiente" }, + runtimeProbe: { preparing: "Preparando o ambiente" }, isolation: { local: "Local", worktree: "Novo worktree", @@ -1143,6 +1145,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 430a1448c3..5af1845b6a 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1122,6 +1122,8 @@ export const ru: TranslationResources = { newWorkspace: { title: "Новое рабочее пространство", create: "Создавать", + runtime: { local: "Локально", worktree: "Worktree", label: "Среда" }, + runtimeProbe: { preparing: "Подготовка среды" }, isolation: { local: "Локально", worktree: "Новый worktree", @@ -1143,6 +1145,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 333fd8a0c1..06c78eb76b 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1089,6 +1089,8 @@ export const zhCN: TranslationResources = { newWorkspace: { title: "新建 workspace", create: "创建", + runtime: { local: "本地", worktree: "Worktree", label: "运行环境" }, + runtimeProbe: { preparing: "正在准备环境" }, isolation: { local: "本地", worktree: "新建 worktree", @@ -1110,6 +1112,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..a4c93cd709 --- /dev/null +++ b/packages/app/src/new-workspace-runtime/model.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "vitest"; +import type { WorkspaceRuntimeCatalogEntry } from "@getpaseo/protocol/messages"; + +import { + resolveWorkspaceProviderProbeTarget, + resolveWorkspaceProviderSnapshotScope, + 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 an unavailable runtime after authoritative catalog resolution", () => { + const selection = resolveWorkspaceRuntimeSelection({ + catalog: { status: "ready", runtimes: [LOCAL, WORKTREE, FIXTURE] }, + selectedRuntimeId: "retired-runtime", + 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"); + }); + + test("renders registration labels for arbitrary runtime ids without id vocabulary", () => { + expect( + runtimeLabel({ runtimeId: "moon-base", label: "Moon Base" }, "runtime", () => "bad"), + ).toBe("Moon Base"); + expect( + runtimeLabel({ runtimeId: "shipyard", label: "Container Lab" }, "runtime", () => "bad"), + ).toBe("Container Lab"); + }); +}); + +describe("new workspace provider probe target", () => { + test("retains the tagged cwd snapshot only below the runtime feature gate", () => { + expect( + resolveWorkspaceProviderProbeTarget({ + supportsWorkspaceRuntimes: false, + probe: { status: "legacy" }, + }), + ).toEqual({ kind: "legacy-cwd" }); + }); + + test("disables provider snapshots until the selected runtime probe is ready", () => { + expect( + resolveWorkspaceProviderProbeTarget({ + supportsWorkspaceRuntimes: true, + probe: { status: "loading" }, + }), + ).toEqual({ kind: "unavailable" }); + expect( + resolveWorkspaceProviderProbeTarget({ + supportsWorkspaceRuntimes: true, + probe: { status: "error", error: "fixture failed" }, + }), + ).toEqual({ kind: "unavailable" }); + }); + + test("keys the existing provider snapshot pipeline by the ensured probe workspace", () => { + expect( + resolveWorkspaceProviderProbeTarget({ + supportsWorkspaceRuntimes: true, + probe: { status: "ready", workspaceId: "probe-fixture" }, + }), + ).toEqual({ kind: "workspace", workspaceId: "probe-fixture" }); + }); + + test("makes a project cwd unreachable while a feature-gated probe is unavailable", () => { + expect(resolveWorkspaceProviderSnapshotScope({ kind: "unavailable" }, "/host/repo")).toEqual({ + enabled: false, + cwd: null, + workspaceId: null, + }); + expect( + resolveWorkspaceProviderSnapshotScope( + { kind: "workspace", workspaceId: "probe-fixture" }, + "/host/repo", + ), + ).toEqual({ enabled: true, cwd: null, workspaceId: "probe-fixture" }); + expect(resolveWorkspaceProviderSnapshotScope({ kind: "legacy-cwd" }, "/host/repo")).toEqual({ + enabled: true, + cwd: "/host/repo", + workspaceId: null, + }); + }); +}); 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..02fc89b917 --- /dev/null +++ b/packages/app/src/new-workspace-runtime/model.ts @@ -0,0 +1,139 @@ +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 WorkspaceProviderProbeState = + | { status: "legacy" } + | { status: "loading" } + | { status: "ready"; workspaceId: string } + | { status: "error"; error: string }; + +export type WorkspaceProviderSnapshotTarget = + | { kind: "legacy-cwd" } + | { kind: "workspace"; workspaceId: string } + | { kind: "unavailable" }; + +export function resolveWorkspaceProviderProbeTarget(input: { + supportsWorkspaceRuntimes: boolean; + probe: WorkspaceProviderProbeState; +}): WorkspaceProviderSnapshotTarget { + if (!input.supportsWorkspaceRuntimes) { + // COMPAT(newWorkspaceCwdProbe): added in v0.3.2, remove after 2027-02-12. + return { kind: "legacy-cwd" }; + } + if (input.probe.status !== "ready") return { kind: "unavailable" }; + return { kind: "workspace", workspaceId: input.probe.workspaceId }; +} + +export function resolveWorkspaceProviderSnapshotScope( + target: WorkspaceProviderSnapshotTarget, + projectCwd: string | null, +): { enabled: boolean; cwd: string | null; workspaceId: string | null } { + if (target.kind === "legacy-cwd") { + return { enabled: true, cwd: projectCwd, workspaceId: null }; + } + if (target.kind === "workspace") { + return { enabled: true, cwd: null, workspaceId: target.workspaceId }; + } + return { enabled: false, cwd: null, workspaceId: null }; +} + +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"; + 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/new-workspace-runtime/use-provider-probe.ts b/packages/app/src/new-workspace-runtime/use-provider-probe.ts new file mode 100644 index 0000000000..e5fa715d60 --- /dev/null +++ b/packages/app/src/new-workspace-runtime/use-provider-probe.ts @@ -0,0 +1,46 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useFetchQuery } from "@/data/query"; + +import type { WorkspaceProviderProbeState } from "./model"; + +export function useWorkspaceProviderProbe(input: { + serverId: string; + projectId: string | null; + runtimeId: string; + supportsWorkspaceRuntimes: boolean; + isConnected: boolean; + client: DaemonClient | null; +}): WorkspaceProviderProbeState & { retry(): void } { + const query = useFetchQuery({ + queryKey: ["workspace-provider-probe", input.serverId, input.projectId, input.runtimeId], + dataShape: "value", + staleTimeMs: 5 * 60_000, + queryFn: async () => { + if (!input.client || !input.projectId) throw new Error("Choose a connected project"); + const result = await input.client.ensureWorkspaceRuntimeProbe({ + projectId: input.projectId, + runtimeId: input.runtimeId, + }); + if (result.status === "error" || !result.workspaceId) { + throw new Error(result.error ?? "Failed to prepare the runtime environment"); + } + return result.workspaceId; + }, + enabled: + input.supportsWorkspaceRuntimes && + input.isConnected && + Boolean(input.client && input.projectId), + retry: false, + }); + const retry = () => void query.refetch(); + if (!input.supportsWorkspaceRuntimes) return { status: "legacy", retry }; + if (query.isError) { + return { + status: "error", + error: query.error instanceof Error ? query.error.message : String(query.error), + retry, + }; + } + if (query.data) return { status: "ready", workspaceId: query.data, retry }; + return { status: "loading", retry }; +} diff --git a/packages/app/src/panels/diff-panel.tsx b/packages/app/src/panels/diff-panel.tsx index f315dd5bca..42cdb79f27 100644 --- a/packages/app/src/panels/diff-panel.tsx +++ b/packages/app/src/panels/diff-panel.tsx @@ -20,6 +20,7 @@ import { import { DiffTooLargeState } from "@/git/diff-too-large-state"; import { useCommitDiffFiles } from "@/git/use-diff-files"; import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff"; +import { useRequiredWorkspaceGit } from "@/git/workspace-git"; import { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { useAppSettings } from "@/hooks/use-settings"; import { usePaneContext } from "@/panels/pane-context"; @@ -155,6 +156,7 @@ function WorkingDiffPanel() { const isConnected = useHostRuntimeIsConnected(serverId); const isActive = useRetainedPanelActive(); const panelPreferences = useDiffPanelPreferences(); + const workspaceGit = useRequiredWorkspaceGit(); const [expandedPaths, setExpandedPaths] = useState(null); invariant(target.kind === "working_diff", "WorkingDiffPanel requires working_diff target"); @@ -180,16 +182,20 @@ function WorkingDiffPanel() { const runRefresh = useCheckoutGitActionsStore((state) => state.refresh); const isRefreshing = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd: cwd ?? "", actionId: "refresh" }), + state.getStatus({ + serverId, + target: workspaceGit, + actionId: "refresh", + }), ) === "pending"; const refresh = useCallback(() => { if (!cwd || isRefreshing) { return; } - void runRefresh({ serverId, cwd }).catch((error) => { + void runRefresh({ serverId, target: workspaceGit }).catch((error) => { toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); }); - }, [cwd, isRefreshing, runRefresh, serverId, t, toast]); + }, [cwd, isRefreshing, runRefresh, serverId, t, toast, workspaceGit]); const expandedPathSet = useMemo( () => (expandedPaths === null ? null : new Set(expandedPaths)), @@ -276,7 +282,6 @@ function CommitDiffPanel() { invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target"); const { files, isLoading, error, capabilityMissing } = useCommitDiffFiles({ serverId, - cwd: cwd ?? "", sha: target.sha, enabled: Boolean(cwd), }); diff --git a/packages/app/src/panels/file-panel.tsx b/packages/app/src/panels/file-panel.tsx index b4b6975e58..18b2fc6796 100644 --- a/packages/app/src/panels/file-panel.tsx +++ b/packages/app/src/panels/file-panel.tsx @@ -43,6 +43,7 @@ function FilePanel() { return ( { provider: "error-provider", status: "error", error: "boom", - models: [], + models: [codexModel], }), snapshotEntry({ provider: "unavailable-provider", diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index ca8be17b85..2f06860a1d 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -104,9 +104,6 @@ function buildEntryModelSelection( entry: ProviderSnapshotEntry, label: string, ): ProviderModelSelection { - if ((entry.models?.length ?? 0) > 0) { - return buildModelSelection(entry.provider, label, entry.models ?? null); - } if (entry.status === "ready") { return buildModelSelection(entry.provider, label, entry.models ?? null); } diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 9725170d9f..95fdebfd00 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -11,6 +11,7 @@ class FakeDirectoryClient { fetchAgentsCalls = 0; lastAgentOptions: unknown; fetchWorkspacesCalls = 0; + lastWorkspaceOptions: unknown; listProjectsCalls = 0; lastProjectOptions: unknown; projectResult: ProjectListResult | null = null; @@ -55,8 +56,9 @@ class FakeDirectoryClient { }; } - async fetchWorkspaces(): Promise { + async fetchWorkspaces(options?: unknown): Promise { this.fetchWorkspacesCalls += 1; + this.lastWorkspaceOptions = options; if (this.pendingWorkspaceFetch) { const pending = this.pendingWorkspaceFetch; this.pendingWorkspaceFetch = null; @@ -147,6 +149,7 @@ describe("DirectorySync session readiness", () => { version: "test", features: { directorySync: true, workspaceMultiplicity: true }, }); + store.setHasHydratedAgents(serverId, true); await directory.refreshAgents({ subscribe: { subscriptionId: `app:${serverId}` }, @@ -250,6 +253,7 @@ describe("DirectorySync session readiness", () => { projectKind: "git", }), ]); + store.setHasHydratedWorkspaces(serverId, true); store.updateSessionServerInfo(serverId, { serverId, hostname: null, @@ -287,6 +291,49 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); + it("requests snapshots when persisted cursors outlive the in-memory replicas", async () => { + const serverId = "fresh-replica-stale-cursors"; + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readDirectoryCheckpoint: () => ({ + agents: { generation: "agents-generation", afterSeq: 3 }, + projects: { generation: "projects-generation", afterSeq: 4 }, + workspaces: { generation: "workspaces-generation", afterSeq: 5 }, + }), + writeDirectoryCheckpoint: () => undefined, + }, + ); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { directorySync: true, workspaceMultiplicity: true, projectList: true }, + }); + + await directory.refreshAll(); + + expect(client.lastAgentOptions).toMatchObject({ sync: {} }); + expect(client.lastProjectOptions).toEqual({ sync: {} }); + expect(client.lastWorkspaceOptions).toMatchObject({ sync: {} }); + directory.dispose(); + }); + it("rejects a session wait on disconnect so the reconnect can refresh", async () => { const serverId = "session-wait-reconnect"; const { client, directory } = createDirectory(serverId); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index ac80a81bd9..53ee5b5440 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -331,7 +331,7 @@ export class DirectorySync { sort: [{ key: "activity_at", direction: "desc" }], ...(subscribe ? { subscribe: {} } : {}), page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT }, - ...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}), + ...(supportsDirectorySync ? { sync: this.readUsableCursors().workspaces ?? {} } : {}), }); this.assertWorkspaceTransactionCurrent(client, source, transaction); if (payload.sync?.mode === "snapshot") transaction.snapshot.workspaces.clear(); @@ -387,7 +387,7 @@ export class DirectorySync { ...(subscribe ? { subscribe } : {}), page: cursor ? { limit, cursor } : { limit }, ...(!input.filter && cursor === null && this.supportsDirectorySync() - ? { sync: this.readCursors().agents ?? {} } + ? { sync: this.readUsableCursors().agents ?? {} } : {}), }); this.assertAgentTransactionCurrent(client, source, transaction); @@ -459,7 +459,7 @@ export class DirectorySync { supportsDirectorySync: boolean, ): Promise { const payload = await client.listProjects( - supportsDirectorySync ? { sync: this.readCursors().projects ?? {} } : undefined, + supportsDirectorySync ? { sync: this.readUsableCursors().projects ?? {} } : undefined, ); this.assertWorkspaceTransactionCurrent(client, source, transaction); if (payload.sync?.mode === "snapshot") transaction.snapshot.projects.clear(); @@ -521,7 +521,7 @@ export class DirectorySync { }); } - private readCursors(): DirectoryCursors { + private readPersistedCursors(): DirectoryCursors { const value = this.checkpoints?.readDirectoryCheckpoint(this.serverId); if (!value || typeof value !== "object" || Array.isArray(value)) return {}; const cursors: DirectoryCursors = {}; @@ -536,9 +536,23 @@ export class DirectorySync { return cursors; } + private readUsableCursors(): DirectoryCursors { + const persisted = this.readPersistedCursors(); + const session = useSessionStore.getState().sessions[this.serverId]; + return { + ...(session?.hasHydratedAgents && persisted.agents ? { agents: persisted.agents } : {}), + ...(session?.hasHydratedWorkspaces && persisted.projects + ? { projects: persisted.projects } + : {}), + ...(session?.hasHydratedWorkspaces && persisted.workspaces + ? { workspaces: persisted.workspaces } + : {}), + }; + } + private writeCursor(entity: keyof DirectoryCursors, cursor: DirectoryCursor): void { if (!this.checkpoints) return; - const current = this.readCursors(); + const current = this.readPersistedCursors(); const previous = current[entity]; if (previous?.generation === cursor.generation && previous.afterSeq >= cursor.afterSeq) return; this.checkpoints.writeDirectoryCheckpoint(this.serverId, { ...current, [entity]: cursor }); 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 2b75b1b2d8..e1bc7f5248 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 { @@ -30,12 +36,13 @@ import { ScreenHeader } from "@/components/headers/screen-header"; import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; -import { useForgeSearchQuery } from "@/git/use-forge-search-query"; -import { useCheckoutStatusQuery } from "@/git/use-status-query"; -import { ensureCheckoutStatus } from "@/git/checkout-status-cache"; +import { useLegacyForgeSearchQuery } from "@/git/use-forge-search-query"; +import { useLegacyCheckoutStatusQuery } from "@/git/use-status-query"; +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"; @@ -118,6 +125,16 @@ import { resolveNewWorkspaceInitialServerId, } from "./new-workspace-initial-context"; import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker"; +import { + resolveWorkspaceProviderProbeTarget, + resolveWorkspaceProviderSnapshotScope, + resolveWorkspaceRuntimeSelection, + runtimeLabel, + type WorkspaceProviderProbeState, + type WorkspaceRuntimeCatalogState, + type WorkspaceRuntimeVocabulary, +} from "@/new-workspace-runtime/model"; +import { useWorkspaceProviderProbe } from "@/new-workspace-runtime/use-provider-probe"; const ThemedFolderPlus = withUnistyles(FolderPlus); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -156,6 +173,53 @@ function isNewWorkspacePending(input: { return input.pendingAction !== null || input.isDraftHandoffActive; } +function isWorkspaceCreationBlocked(input: { + isPending: boolean; + canCreateWorkspace: boolean; + isProviderProbeReady: boolean; +}): boolean { + return input.isPending || !input.canCreateWorkspace || !input.isProviderProbeReady; +} + +function WorkspaceCreationError({ error }: { error: string | null }): ReactElement | null { + return error ? {error} : null; +} + +function WorkspaceProviderProbeFeedback({ + probe, + onRetry, +}: { + probe: WorkspaceProviderProbeState; + onRetry: () => void; +}): ReactElement | null { + const { t } = useTranslation(); + if (probe.status === "legacy" || probe.status === "ready") return null; + if (probe.status === "loading") { + return ( + + {t("newWorkspace.runtimeProbe.preparing")} + + ); + } + return ( + + + {probe.error} + + + {t("common.actions.retry")} + + + ); +} + +function resolveWorkspaceCreationError( + actionError: string | null, + catalogError: string | null, +): string | null { + return actionError ?? catalogError; +} + function buildFirstAgentContext(input: { prompt: string; attachments: AgentAttachment[]; @@ -409,7 +473,7 @@ function PickerOptionItem({ ); } -function IsolationOptionItem({ +function RuntimeOptionItem({ optionId, label, selected, @@ -431,18 +495,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; @@ -647,19 +707,15 @@ function IsolationPickerTrigger({ - {isolation === "worktree" ? ( - - ) : ( - - )} + {label} @@ -679,53 +735,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 setIsolation = useCallback( - (value: "local" | "worktree") => { - setManualIsolation(value); - void updatePreferences({ isolation: value }); + 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 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 }]; @@ -796,7 +847,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; @@ -812,12 +864,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", @@ -844,6 +897,14 @@ async function createMultiplicityWorkspace(input: { return normalizedWorkspace; } +function resolveSelectedHostProjectId( + project: HostProjectListItem | null, + serverId: string, +): string | null { + if (!project) return null; + return getHostProjectId(project, serverId) ?? null; +} + interface CreateChatAgentInput { payload: MessagePayload; composerState: ReturnType["composerState"]; @@ -968,13 +1029,28 @@ function buildComposerConfig(input: { workspaceDirectory: string | null; sourceDirectory: string | null; initialSetup?: WorkspaceDraftTabSetup | null; + providerSnapshotTarget: ReturnType; }): Parameters[0]["composer"] { - const { serverId, isConnected, workspaceDirectory, sourceDirectory, initialSetup } = input; + const { + serverId, + isConnected, + workspaceDirectory, + sourceDirectory, + initialSetup, + providerSnapshotTarget, + } = input; const workingDir = workspaceDirectory || sourceDirectory || undefined; + const providerSnapshotScope = resolveWorkspaceProviderSnapshotScope( + providerSnapshotTarget, + workingDir ?? null, + ); return { initialServerId: serverId || null, initialValues: buildComposerInitialValues({ workingDir, initialSetup }), initialFeatureValues: initialSetup?.featureValues, + workspaceId: providerSnapshotScope.workspaceId, + providerSnapshotCwd: providerSnapshotScope.cwd, + isTargetDaemonReady: providerSnapshotScope.enabled, isVisible: true, onlineServerIds: isConnected && serverId ? [serverId] : [], lockedWorkingDir: workingDir, @@ -1311,12 +1387,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; @@ -1339,15 +1417,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], @@ -1445,31 +1571,13 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme ) : null; - const isolationControl = isolation.canCreateWorktree ? ( - - - - + const runtimeControl = runtime.showControl ? ( + ) : null; const baseControl = base.showRefPicker ? ( @@ -1519,18 +1627,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} @@ -1538,6 +1646,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, @@ -1571,6 +1701,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 }); @@ -1630,6 +1762,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, @@ -1671,6 +1809,36 @@ export function NewWorkspaceScreen({ const projectIconDataByProjectViewKey = useProjectIcons({ projects: projectIconTargets, }); + const worktreeSupport = selectedProject + ? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId }) + : "unsupported"; + const { + runtimeId, + setRuntimeId, + availableRuntimes, + showRuntimeControl, + showRefPicker, + canCreateWorkspace, + catalogError, + vocabulary: runtimeVocabulary, + } = useWorkspaceRuntime({ + supportsMultiplicity: supportsWorkspaceMultiplicity, + worktreeSupport, + catalog: runtimeCatalog, + }); + const selectedHostProjectId = resolveSelectedHostProjectId(selectedProject, selectedServerId); + const providerProbe = useWorkspaceProviderProbe({ + serverId: selectedServerId, + projectId: selectedHostProjectId, + runtimeId, + supportsWorkspaceRuntimes, + isConnected, + client, + }); + const providerProbeTarget = resolveWorkspaceProviderProbeTarget({ + supportsWorkspaceRuntimes, + probe: providerProbe, + }); const draftKey = buildNewWorkspaceDraftKey(draftId); const forkDraftSetup = usePendingWorkspaceDraftSetup(draftId); const draftContextScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId); @@ -1686,6 +1854,7 @@ export function NewWorkspaceScreen({ workspaceDirectory: workspace?.workspaceDirectory ?? null, sourceDirectory: selectedSourceDirectory, initialSetup: forkDraftSetup?.setup, + providerSnapshotTarget: providerProbeTarget, }), }); const composerState = chatDraft.composerState; @@ -1717,20 +1886,17 @@ export function NewWorkspaceScreen({ const hasSelectedSourceDirectory = selectedSourceDirectory !== null; const pickerQueryEnabled = pickerOpen && clientReady && hasSelectedSourceDirectory; - const { status: checkoutStatus } = useCheckoutStatusQuery({ + const { status: checkoutStatus } = useLegacyCheckoutStatusQuery({ serverId: selectedServerId, cwd: selectedSourceDirectory ?? "", }); - const worktreeSupport = selectedProject - ? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId }) - : "unsupported"; const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive }); - const { effectiveIsolation, setIsolation, canCreateWorktree, showRefPicker } = - useWorkspaceIsolation({ - supportsMultiplicity: supportsWorkspaceMultiplicity, - worktreeSupport, - }); + const isCreationBlocked = isWorkspaceCreationBlocked({ + isPending, + canCreateWorkspace, + isProviderProbeReady: providerProbeTarget.kind !== "unavailable", + }); const branchSuggestionsQuery = useQuery({ queryKey: [ @@ -1754,7 +1920,7 @@ export function NewWorkspaceScreen({ staleTime: 15_000, }); - const githubPrSearchQuery = useForgeSearchQuery({ + const githubPrSearchQuery = useLegacyForgeSearchQuery({ client, serverId: selectedServerId, cwd: selectedSourceDirectory ?? "", @@ -1878,31 +2044,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 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 handleSelectIsolationOption = useCallback( + const handleSelectRuntimeOption = useCallback( (id: string) => { - setIsolation(id === "worktree" ? "worktree" : "local"); - setIsolationPickerOpen(false); + setRuntimeId(id); + setRuntimePickerOpen(false); }, - [setIsolation], + [setRuntimeId], ); - const renderIsolationOption = useCallback( + const renderRuntimeOption = useCallback( ({ option, selected, @@ -1915,7 +2093,7 @@ export function NewWorkspaceScreen({ onPress: () => void; }) => { return ( - , []); return ( - - - - - - - {t("newWorkspace.title")} - - {formStack} - {isTerminalLaunch ? ( - - ) : ( - + + + + + + + {t("newWorkspace.title")} + + {formStack} + {isTerminalLaunch ? ( + + ) : ( + + )} + - )} - {errorMessage ? {errorMessage} : null} - - - + + + + + ); } @@ -2395,6 +2584,21 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.destructive, lineHeight: 20, }, + probeStatusText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + lineHeight: 20, + }, + probeErrorRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + probeRetryText: { + fontSize: theme.fontSize.sm, + color: theme.colors.accent, + lineHeight: 20, + }, formStack: { marginBottom: theme.spacing[8], gap: theme.spacing[2], diff --git a/packages/app/src/screens/workspace/use-workspace-checkout-status.ts b/packages/app/src/screens/workspace/use-workspace-checkout-status.ts index e51f5eef23..668ba0a9fa 100644 --- a/packages/app/src/screens/workspace/use-workspace-checkout-status.ts +++ b/packages/app/src/screens/workspace/use-workspace-checkout-status.ts @@ -4,10 +4,10 @@ import { useTranslation } from "react-i18next"; import { checkoutStatusQueryKey } from "@/git/query-keys"; import { fetchCheckoutStatus } from "@/git/checkout-status-cache"; import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state"; -import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import type { WorkspaceGitClient } from "@/git/workspace-git"; interface UseWorkspaceCheckoutStatusInput { - client: ReturnType; + workspaceGit: WorkspaceGitClient | null; isConnected: boolean; isRouteFocused: boolean; normalizedServerId: string; @@ -21,26 +21,25 @@ export function useWorkspaceCheckoutStatus(input: UseWorkspaceCheckoutStatusInpu () => canCreateWorkspaceTerminal({ isRouteFocused: input.isRouteFocused, - client: input.client, + client: input.workspaceGit, isConnected: input.isConnected, workspaceDirectory: input.workspaceDirectory, }), - [input.client, input.isConnected, input.isRouteFocused, input.workspaceDirectory], + [input.isConnected, input.isRouteFocused, input.workspaceDirectory, input.workspaceGit], ); const checkoutQuery = useQuery({ - queryKey: checkoutStatusQueryKey( - input.normalizedServerId, - input.workspaceDirectory ?? `missing-workspace-directory:${input.normalizedWorkspaceId}`, - ), + queryKey: input.workspaceGit + ? checkoutStatusQueryKey(input.normalizedServerId, input.workspaceGit) + : (["checkoutStatus", input.normalizedServerId, "unbound"] as const), enabled: isCheckoutQueryEnabled, queryFn: async () => { - if (!input.client || !input.workspaceDirectory) { + if (!input.workspaceGit) { throw new Error(t("workspace.terminal.hostDisconnected")); } return await fetchCheckoutStatus({ - client: input.client, + client: input.workspaceGit, serverId: input.normalizedServerId, - cwd: input.workspaceDirectory, + target: input.workspaceGit, }); }, staleTime: Infinity, diff --git a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx index 15e9ef09fd..a3231b4839 100644 --- a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx +++ b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx @@ -16,7 +16,6 @@ import { useToast } from "@/contexts/toast-context"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; -import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor"; import { openExternalUrl } from "@/utils/open-external-url"; import { isAbsolutePath } from "@/utils/path"; @@ -30,7 +29,9 @@ import { getForgePresentation } from "@/git/forge"; interface WorkspaceOpenInEditorButtonProps { serverId: string; + workspaceId: string; cwd: string; + hostVisiblePath?: string | null; activeFile?: WorkspaceFileLocation | null; hideLabels?: boolean; } @@ -81,12 +82,12 @@ function OpenTargetMenuItem({ target, isPreferred, onOpen }: OpenTargetMenuItemP export function WorkspaceOpenInEditorButton({ serverId, cwd, + hostVisiblePath, activeFile, hideLabels, }: WorkspaceOpenInEditorButtonProps) { const { t } = useTranslation(); const toast = useToast(); - const isConnected = useHostRuntimeIsConnected(serverId); const isLocalDaemon = useIsLocalDaemon(serverId); const { preferredEditorId, updatePreferredEditor } = usePreferredEditor(); const { targets: desktopOpenTargets, isAvailable: isDesktopOpenAvailable } = @@ -96,8 +97,10 @@ export function WorkspaceOpenInEditorButton({ const resolvedFile = useMemo( () => - activeFile ? resolveWorkspaceFilePaths({ path: activeFile.path, workspaceRoot: cwd }) : null, - [activeFile, cwd], + activeFile && hostVisiblePath + ? resolveWorkspaceFilePaths({ path: activeFile.path, workspaceRoot: hostVisiblePath }) + : null, + [activeFile, hostVisiblePath], ); const activeFileName = useMemo( () => resolvedFile?.absolutePath.split("/").findLast(Boolean) ?? null, @@ -105,21 +108,18 @@ export function WorkspaceOpenInEditorButton({ ); const canResolveWorkspace = isWeb && cwd.trim().length > 0 && isAbsolutePath(cwd); - const shouldQueryCheckout = canResolveWorkspace && isConnected; - const { status: checkoutStatus } = useCheckoutStatusQuery({ serverId, - cwd: shouldQueryCheckout ? cwd : "", }); const { resolvedForge } = useCheckoutPrStatusQuery({ serverId, - cwd: shouldQueryCheckout ? cwd : "", }); const targets = useMemo( () => planWorkspaceOpenTargets({ workspaceDirectory: cwd, + hostVisiblePath, activeFile, resolvedActiveFile: resolvedFile, desktopTargets: desktopOpenTargets, @@ -151,6 +151,7 @@ export function WorkspaceOpenInEditorButton({ checkoutStatus, cwd, desktopOpenTargets, + hostVisiblePath, resolvedForge, isDesktopOpenAvailable, isLocalDaemon, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index f10e627072..6f84524e99 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -63,6 +63,7 @@ import { RetainedPanel } from "@/components/retained-panel"; import { WindowChromeRegion } from "@/utils/desktop-window"; import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon"; import { WorkspaceActions } from "@/git/workspace-actions"; +import { useBoundWorkspaceGit, WorkspaceGitBoundary } from "@/git/workspace-git"; import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button"; import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button"; import { ImportSessionSheet } from "@/components/import-session-sheet"; @@ -1738,6 +1739,18 @@ function WorkspaceScreenContent({ (state) => state.sessions[normalizedServerId]?.serverInfo?.features?.providersSnapshot === true, ); const workspaceDirectory = workspaceDescriptor?.workspaceDirectory || null; + const workspaceGitAddress = useMemo( + () => + normalizedWorkspaceId && workspaceDirectory + ? ({ + kind: "selected", + workspaceId: normalizedWorkspaceId, + cwd: workspaceDirectory, + } as const) + : null, + [normalizedWorkspaceId, workspaceDirectory], + ); + const workspaceGit = useBoundWorkspaceGit(client, workspaceGitAddress); const isMissingWorkspaceDirectory = Boolean(workspaceDescriptor) && !workspaceDirectory; const [isImportSheetVisible, setIsImportSheetVisible] = useState(false); const canOpenImportSheet = [client, isConnected, workspaceDirectory].every(Boolean); @@ -1758,12 +1771,16 @@ function WorkspaceScreenContent({ ) { return; } - prefetchProvidersSnapshot(normalizedServerId, client, { cwd: workspaceDirectory }); + prefetchProvidersSnapshot(normalizedServerId, client, { + cwd: workspaceDirectory, + workspaceId: normalizedWorkspaceId, + }); }, [ client, isConnected, isRouteFocused, normalizedServerId, + normalizedWorkspaceId, supportsProvidersSnapshot, workspaceDirectory, ]); @@ -1868,7 +1885,7 @@ function WorkspaceScreenContent({ const { archiveAgent } = useArchiveAgent(); const { checkoutQuery, isCheckoutStatusLoading } = useWorkspaceCheckoutStatus({ - client, + workspaceGit, isConnected, isRouteFocused, normalizedServerId, @@ -3533,7 +3550,9 @@ function WorkspaceScreenContent({ {!isMobile && workspaceDirectory ? ( @@ -3542,7 +3561,9 @@ function WorkspaceScreenContent({ <> {isGitCheckout ? ( @@ -3652,6 +3673,7 @@ function WorkspaceScreenContent({ handleViewScriptTerminal, handleOpenUrlInBrowserTab, showCompactButtonLabels, + workspaceGit, isGitCheckout, handleToggleExplorer, isExplorerOpen, @@ -3667,8 +3689,10 @@ function WorkspaceScreenContent({ [isFocusModeEnabled, isMobile], ); const showExplorerSidebar = useMemo( - () => shouldShowWorkspaceExplorerSidebar({ isRouteFocused, isFocusModeEnabled, isMobile }), - [isRouteFocused, isFocusModeEnabled, isMobile], + () => + Boolean(workspaceGit) && + shouldShowWorkspaceExplorerSidebar({ isRouteFocused, isFocusModeEnabled, isMobile }), + [isRouteFocused, isFocusModeEnabled, isMobile, workspaceGit], ); const createTerminalDisabled = useMemo( () => createTerminalMutation.isPending || pendingTerminalCreateInput !== null, @@ -3881,46 +3905,48 @@ function WorkspaceScreenContent({ ); return ( - gatedWorkspaceScreen ?? ( - - - - - - {workspaceCenterColumn} - - - - - - - ) + + {gatedWorkspaceScreen ?? ( + + + + + + {workspaceCenterColumn} + + + + + + + )} + ); } diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index aeed4991a4..3b618b3b66 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -112,6 +112,7 @@ export interface WorkspaceDescriptor { projectCustomIconRevision?: string | null; projectRootPath: string; workspaceDirectory: string; + hostVisiblePath?: string; worktreeSlug?: WorkspaceDescriptorPayload["worktreeSlug"]; projectKind: WorkspaceDescriptorPayload["projectKind"]; workspaceKind: WorkspaceDescriptorPayload["workspaceKind"]; @@ -148,6 +149,7 @@ export function normalizeWorkspaceDescriptor( // consumer can read workspace.workspaceDirectory directly. Empty means "no // usable directory" (older daemons may omit it; the wire field is optional). workspaceDirectory: normalizeWorkspacePath(payload.workspaceDirectory) ?? "", + hostVisiblePath: normalizeWorkspacePath(payload.hostVisiblePath) ?? undefined, worktreeSlug: payload.worktreeSlug, projectKind: payload.projectKind, workspaceKind: payload.workspaceKind, diff --git a/packages/app/src/subagents/archive-finished.test.ts b/packages/app/src/subagents/archive-finished.test.ts index 6611b93f8a..42a3e51ca6 100644 --- a/packages/app/src/subagents/archive-finished.test.ts +++ b/packages/app/src/subagents/archive-finished.test.ts @@ -400,9 +400,11 @@ describe("createArchiveFinishedSubagents", () => { }); it("dismisses finished provider rows locally without removing their descriptors", async () => { + const finished = provider("finished"); + const running = provider("running", "running"); const descriptors = new Map([ - ["finished", provider("finished")], - ["running", provider("running", "running")], + ["finished", finished], + ["running", running], ]); const dismissed: string[][] = []; const archive = createArchiveFinishedSubagents([...descriptors.values()], { @@ -420,7 +422,7 @@ describe("createArchiveFinishedSubagents", () => { }); expect(dismissed).toEqual([["finished"]]); - expect(descriptors.get("finished")).toEqual(provider("finished")); - expect(descriptors.get("running")).toEqual(provider("running", "running")); + expect(descriptors.get("finished")).toBe(finished); + expect(descriptors.get("running")).toBe(running); }); }); diff --git a/packages/app/src/workspace/open-target-planner.test.ts b/packages/app/src/workspace/open-target-planner.test.ts index 471b594cef..2c26a8b7b3 100644 --- a/packages/app/src/workspace/open-target-planner.test.ts +++ b/packages/app/src/workspace/open-target-planner.test.ts @@ -26,6 +26,7 @@ describe("planWorkspaceOpenTargets", () => { it("plans editor targets with active-file absolute path and cwd", () => { const targets = planWorkspaceOpenTargets({ workspaceDirectory: "/repo", + hostVisiblePath: "/repo", activeFile: { path: "src/app.ts", lineStart: 3, lineEnd: 5 }, desktopTargets, canUseDesktopBridge: true, @@ -47,6 +48,7 @@ describe("planWorkspaceOpenTargets", () => { it("plans file-manager targets with active-file absolute path and reveal mode", () => { const targets = planWorkspaceOpenTargets({ workspaceDirectory: "/repo", + hostVisiblePath: "/repo", activeFile: { path: "src/app.ts" }, desktopTargets, canUseDesktopBridge: true, @@ -67,6 +69,7 @@ describe("planWorkspaceOpenTargets", () => { it("plans no active file as opening the workspace folder", () => { const targets = planWorkspaceOpenTargets({ workspaceDirectory: "/repo", + hostVisiblePath: "/repo", desktopTargets, canUseDesktopBridge: true, isLocalExecution: true, @@ -87,6 +90,7 @@ describe("planWorkspaceOpenTargets", () => { it("passes custom target ids through as strings", () => { const targets = planWorkspaceOpenTargets({ workspaceDirectory: "/repo", + hostVisiblePath: "/repo", activeFile: { path: "src/app.ts" }, desktopTargets: [ { @@ -202,4 +206,17 @@ describe("planWorkspaceOpenTargets", () => { expect(targets.map((target) => target.id)).toEqual(["github"]); }); + + it("does not treat a runtime-local compatibility cwd as a host-visible path", () => { + const targets = planWorkspaceOpenTargets({ + workspaceDirectory: "/workspace", + hostVisiblePath: null, + desktopTargets, + canUseDesktopBridge: true, + isLocalExecution: true, + checkoutStatus, + }); + + expect(targets.map((target) => target.id)).toEqual(["github"]); + }); }); diff --git a/packages/app/src/workspace/open-target-planner.ts b/packages/app/src/workspace/open-target-planner.ts index ca1e786ecb..c716fb2792 100644 --- a/packages/app/src/workspace/open-target-planner.ts +++ b/packages/app/src/workspace/open-target-planner.ts @@ -33,6 +33,7 @@ export type PlannedWorkspaceOpenTarget = PlannedDesktopOpenTarget | PlannedForge export interface PlanWorkspaceOpenTargetsInput { workspaceDirectory: string; + hostVisiblePath?: string | null; activeFile?: WorkspaceFileLocation | null; resolvedActiveFile?: ResolvedWorkspaceFilePaths | null; desktopTargets: readonly DesktopOpenTarget[]; @@ -60,16 +61,17 @@ function resolveActiveFileForOpenTargets( } function planDesktopOpenTargets(input: { - workspaceDirectory: string; + hostVisiblePath?: string | null; activeFile?: WorkspaceFileLocation | null; resolvedFile: ResolvedWorkspaceFilePaths | null; desktopTargets: readonly DesktopOpenTarget[]; canUseDesktopBridge: boolean; isLocalExecution: boolean; }): PlannedDesktopOpenTarget[] { - if (!input.canUseDesktopBridge || !input.isLocalExecution) { + if (!input.canUseDesktopBridge || !input.isLocalExecution || !input.hostVisiblePath) { return []; } + const hostVisiblePath = input.hostVisiblePath; return input.desktopTargets.map((target) => { if (!input.resolvedFile) { @@ -79,7 +81,7 @@ function planDesktopOpenTargets(input: { label: target.label, editorId: target.id, icon: target.icon, - openInput: { editorId: target.id, workspacePath: input.workspaceDirectory }, + openInput: { editorId: target.id, workspacePath: hostVisiblePath }, }; } return { @@ -90,7 +92,7 @@ function planDesktopOpenTargets(input: { icon: target.icon, openInput: { editorId: target.id, - workspacePath: input.workspaceDirectory, + workspacePath: hostVisiblePath, filePath: input.resolvedFile.absolutePath, ...(input.activeFile?.lineStart ? { line: input.activeFile.lineStart } : {}), }, diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index a7afb4e6b7..947af958d6 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -98,12 +98,10 @@ const DETACHED_STARTUP_GRACE_MS = 1200; const PID_POLL_INTERVAL_MS = 100; const DAEMON_LOG_FILENAME = "daemon.log"; const DAEMON_PID_FILENAME = "paseo.pid"; - +const require = createRequire(import.meta.url); export const DEFAULT_STOP_TIMEOUT_MS = 15_000; export const DEFAULT_KILL_TIMEOUT_MS = 3_000; -const require = createRequire(import.meta.url); - const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = { resolveRunnerEntry: resolveDaemonRunnerEntry, resolveHome: resolvePaseoHome, diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index b9de60d514..49b19c6bd7 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1981,7 +1981,7 @@ test("listDirectory sends a list file explorer request and returns directory ent mock.triggerOpen(); await connectPromise; - const responsePromise = client.listDirectory("/tmp/project", "src", "req-list"); + const responsePromise = client.listDirectory("/tmp/project", "src", "req-list", "workspace-1"); expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({ type: "session", @@ -1990,6 +1990,7 @@ test("listDirectory sends a list file explorer request and returns directory ent cwd: "/tmp/project", path: "src", mode: "list", + workspaceId: "workspace-1", requestId: "req-list", }, }); @@ -2950,6 +2951,91 @@ 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 probePromise = client.ensureWorkspaceRuntimeProbe( + { projectId: "project-1", runtimeId: "fixture" }, + "req-runtime-probe", + ); + expect(parseSentFrame(mock.sent.at(-1)!)).toEqual({ + type: "workspace.runtime.ensure_probe.request", + projectId: "project-1", + runtimeId: "fixture", + requestId: "req-runtime-probe", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "workspace.runtime.ensure_probe.response", + payload: { + requestId: "req-runtime-probe", + workspaceId: "probe-fixture", + status: "ready", + error: null, + }, + }), + ); + await expect(probePromise).resolves.toMatchObject({ + workspaceId: "probe-fixture", + status: "ready", + }); + + 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(); @@ -3964,6 +4050,199 @@ test("requests checkout pull via RPC", async () => { }); }); +test("bound workspace Git carries identity for the normal mutation surface", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + const workspaceGit = client.bindWorkspaceGit({ workspaceId: "workspace-a", cwd: "/shared" }); + + const pull = workspaceGit.pull("bound-pull"); + const request = parseSentFrame(mock.sent[0]); + expect(request).toMatchObject({ + type: "checkout_pull_request", + workspaceId: "workspace-a", + cwd: "/shared", + requestId: "bound-pull", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "checkout_pull_response", + payload: { cwd: "/shared", requestId: "bound-pull", success: true, error: null }, + }), + ); + await expect(pull).resolves.toMatchObject({ success: true }); + + const discard = workspaceGit.discardChanges({ paths: ["changed.ts"] }); + const discardRequest = parseSentFrame(mock.sent[1]); + expect(discardRequest).toMatchObject({ + type: "checkout.discard_changes.request", + workspaceId: "workspace-a", + cwd: "/shared", + paths: ["changed.ts"], + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "checkout.discard_changes.response", + payload: { + cwd: "/shared", + requestId: discardRequest.requestId, + success: true, + error: null, + }, + }), + ); + await expect(discard).resolves.toMatchObject({ success: true }); + + const checkDetails = workspaceGit.getForgeCheckDetails( + { repoOwner: "getpaseo", repoName: "paseo", checkRunId: 42 }, + "bound-check-details", + ); + expect(parseSentFrame(mock.sent[2])).toMatchObject({ + type: "checkout.forge.get_check_details.request", + workspaceId: "workspace-a", + cwd: "/shared", + repoOwner: "getpaseo", + repoName: "paseo", + checkRunId: 42, + requestId: "bound-check-details", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "checkout.forge.get_check_details.response", + payload: { + cwd: "/shared", + requestId: "bound-check-details", + success: true, + details: { + checkRunId: 42, + name: "Runtime check", + output: { title: "Runtime check", summary: "runtime logs", text: "runtime output" }, + annotations: [], + failedJobs: [], + truncated: false, + }, + error: null, + }, + }), + ); + await expect(checkDetails).resolves.toMatchObject({ + success: true, + details: { output: { text: "runtime output" } }, + }); + + expect(Object.keys(workspaceGit)).toEqual( + expect.arrayContaining([ + "getStatus", + "getDiff", + "subscribeDiff", + "commit", + "listCommits", + "getCommitFileDiff", + "getBranchSuggestions", + "switchBranch", + "stashSave", + "stashPop", + "stashList", + "pull", + "refresh", + ]), + ); +}); + +test.each(["", " "])( + "selected workspace Git rejects identity %j before it can become a legacy request", + (workspaceId) => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + expect(() => client.bindWorkspaceGit({ workspaceId, cwd: "/shared" })).toThrow( + "workspaceId is required for selected workspace Git", + ); + expect(mock.sent).toEqual([]); + }, +); + +test.each(["", " "])( + "selected workspace Git rejects cwd %j before sending a daemon request", + (cwd) => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + expect(() => client.bindWorkspaceGit({ workspaceId: "workspace-a", cwd })).toThrow( + "cwd is required for selected workspace Git", + ); + expect(mock.sent).toEqual([]); + }, +); + +test("selected workspace Git normalizes its complete address before sending", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const workspaceGit = client.bindWorkspaceGit({ + workspaceId: " workspace-a ", + cwd: " /shared ", + }); + const status = workspaceGit.getStatus({ requestId: "normalized-status" }); + + expect(parseSentFrame(mock.sent[0])).toMatchObject({ + type: "checkout_status_request", + workspaceId: "workspace-a", + cwd: "/shared", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "checkout_status_response", + payload: { + cwd: "/shared", + requestId: "normalized-status", + isGit: false, + isPaseoOwnedWorktree: false, + repoRoot: null, + currentBranch: null, + isDirty: null, + baseRef: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + hasRemote: false, + remoteUrl: null, + error: null, + }, + }), + ); + await status; +}); + test("renames a branch via RPC", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 5ebdb1fe43..58983b4735 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -102,6 +102,8 @@ import type { PaseoConfigRevision, WorkspaceCreateRequest, WorkspaceRecoveryState, + WorkspaceRuntimeListPayload, + WorkspaceRuntimeEnsureProbePayload, PluginListItem, } from "@getpaseo/protocol/messages"; import type { @@ -1058,12 +1060,13 @@ export class DaemonClient { { cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }; + workspaceId?: string; } >(); private terminalDirectorySubscriptions = new Map(); private fileSubscriptions = new Map< string, - { cwd: string; path: string; onUpdate: (version: FileVersion) => void } + { cwd: string; path: string; workspaceId?: string; onUpdate: (version: FileVersion) => void } >(); private readonly terminalStreams = new TerminalStreamRouter(); private pendingBinaryFileReads = new Map(); @@ -2062,6 +2065,7 @@ export class DaemonClient { const message = SessionInboundMessageSchema.parse({ type: "fetch_recent_provider_sessions_request", requestId: resolvedRequestId, + ...(options?.workspaceId ? { workspaceId: options.workspaceId } : {}), ...(options?.cwd ? { cwd: options.cwd } : {}), ...(options?.providers ? { providers: options.providers } : {}), ...(options?.since ? { since: options.since } : {}), @@ -2130,6 +2134,25 @@ export class DaemonClient { }); } + async listWorkspaceRuntimes(requestId?: string): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "workspace.runtime.list.request" }, + responseType: "workspace.runtime.list.response", + }); + } + + async ensureWorkspaceRuntimeProbe( + input: { projectId: string; runtimeId: string }, + requestId?: string, + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "workspace.runtime.ensure_probe.request", ...input }, + responseType: "workspace.runtime.ensure_probe.response", + }); + } + async openProject(cwd: string, requestId?: string): Promise { return this.sendCorrelatedSessionRequest({ requestId, @@ -2336,6 +2359,7 @@ export class DaemonClient { type: "subscribe_checkout_diff_request", subscriptionId, cwd: subscription.cwd, + ...(subscription.workspaceId ? { workspaceId: subscription.workspaceId } : {}), compare: subscription.compare, requestId: this.createRequestId(), }); @@ -2366,6 +2390,7 @@ export class DaemonClient { cwd: subscription.cwd, path: subscription.path, subscriptionId, + ...(subscription.workspaceId ? { workspaceId: subscription.workspaceId } : {}), }, responseType: "fs.file.subscribe.response", }) @@ -3521,14 +3546,256 @@ export class DaemonClient { // Git Operations // ============================================================================ + bindWorkspaceGit(target: { workspaceId: string; cwd: string }) { + const workspaceId = target.workspaceId.trim(); + if (workspaceId.length === 0) { + throw new Error("workspaceId is required for selected workspace Git"); + } + const cwd = target.cwd.trim(); + if (cwd.length === 0) { + throw new Error("cwd is required for selected workspace Git"); + } + const address = { workspaceId, cwd } as const; + return { + workspaceId, + cwd, + getStatus: (options?: { requestId?: string }) => + this.requestCheckoutStatus(cwd, { ...options, workspaceId }), + getDiff: ( + compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, + requestId?: string, + ) => this.getBoundCheckoutDiff({ workspaceId, cwd, compare, requestId }), + subscribeDiff: ( + compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, + options?: { subscriptionId?: string; requestId?: string }, + ) => this.subscribeCheckoutDiff(cwd, compare, { ...options, workspaceId }), + unsubscribeDiff: (subscriptionId: string) => this.unsubscribeCheckoutDiff(subscriptionId), + commit: (input: { message?: string; addAll?: boolean }, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout_commit_request", workspaceId, cwd, ...input }, + responseType: "checkout_commit_response", + }), + merge: ( + input: { baseRef?: string; strategy?: "merge" | "squash"; requireCleanTarget?: boolean }, + requestId?: string, + ) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout_merge_request", ...address, ...input }, + responseType: "checkout_merge_response", + }), + mergeFromBase: ( + input: { baseRef?: string; requireCleanTarget?: boolean }, + requestId?: string, + ) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout_merge_from_base_request", ...address, ...input }, + responseType: "checkout_merge_from_base_response", + }), + pull: (requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout_pull_request", ...address }, + responseType: "checkout_pull_response", + }), + push: (requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout_push_request", ...address }, + responseType: "checkout_push_response", + }), + refresh: (requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout.refresh.request", workspaceId, cwd }, + responseType: "checkout.refresh.response", + }), + listCommits: async (requestId?: string) => { + const payload = + await this.sendNamespacedCorrelatedSessionRequest<"checkout.commits.list.response">({ + requestId, + message: { type: "checkout.commits.list.request", ...address }, + timeout: 60000, + }); + if (payload.error) throw new Error(payload.error.message); + return { baseRef: payload.baseRef, commits: payload.commits }; + }, + getCommitFileDiff: async (sha: string, path: string, requestId?: string) => { + const payload = + await this.sendNamespacedCorrelatedSessionRequest<"checkout.commits.file_diff.response">({ + requestId, + message: { type: "checkout.commits.file_diff.request", ...address, sha, path }, + timeout: 60000, + }); + if (payload.error) throw new Error(payload.error.message); + return { file: payload.file }; + }, + discardChanges: (input: { paths: string[] }) => + this.checkoutDiscardChanges(cwd, { ...input, workspaceId }), + createPr: (input: { title?: string; body?: string; baseRef?: string }, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout_pr_create_request", ...address, ...input }, + responseType: "checkout_pr_create_response", + }), + mergePr: (method: CheckoutPrMergeMethod, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout_pr_merge_request", ...address, mergeMethod: method }, + responseType: "checkout_pr_merge_response", + }), + setForgeAutoMerge: ( + input: { enabled: true; method: CheckoutPrMergeMethod } | { enabled: false }, + requestId?: string, + ) => + this.sendNamespacedCorrelatedSessionRequest<"checkout.forge.set_auto_merge.response">({ + requestId, + message: { + type: "checkout.forge.set_auto_merge.request", + ...address, + enabled: input.enabled, + ...(input.enabled ? { mergeMethod: input.method } : {}), + }, + timeout: 60000, + }), + setGithubAutoMerge: ( + input: { enabled: true; method: CheckoutPrMergeMethod } | { enabled: false }, + requestId?: string, + ) => + this.sendNamespacedCorrelatedSessionRequest<"checkout.github.set_auto_merge.response">({ + requestId, + message: { + type: "checkout.github.set_auto_merge.request", + ...address, + enabled: input.enabled, + ...(input.enabled ? { mergeMethod: input.method } : {}), + }, + }), + getPrStatus: (requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout_pr_status_request", ...address }, + responseType: "checkout_pr_status_response", + }), + getPullRequestTimeline: ( + input: { prNumber: number; repoOwner: string; repoName: string }, + requestId?: string, + ) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "pull_request_timeline_request", ...address, ...input }, + responseType: "pull_request_timeline_response", + }), + validateBranch: (branchName: string, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "validate_branch_request", ...address, branchName }, + responseType: "validate_branch_response", + }), + switchBranch: (branch: string, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout_switch_branch_request", workspaceId, cwd, branch }, + responseType: "checkout_switch_branch_response", + }), + renameBranch: (branch: string, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "checkout.rename_branch.request", ...address, branch }, + responseType: "checkout.rename_branch.response", + }), + stashSave: (options?: { branch?: string }, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "stash_save_request", ...address, branch: options?.branch }, + responseType: "stash_save_response", + }), + stashPop: (stashIndex: number, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "stash_pop_request", ...address, stashIndex }, + responseType: "stash_pop_response", + }), + stashList: (options?: { paseoOnly?: boolean }, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "stash_list_request", ...address, paseoOnly: options?.paseoOnly }, + responseType: "stash_list_response", + }), + getBranchSuggestions: (options?: { query?: string; limit?: number }, requestId?: string) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "branch_suggestions_request", ...address, ...options }, + responseType: "branch_suggestions_response", + }), + searchForge: ( + options: { query: string; limit?: number; kinds?: ForgeSearchRequest["kinds"] }, + requestId?: string, + ) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "forge.search.request", ...address, ...options }, + responseType: "forge.search.response", + timeout: 15000, + }), + searchGitHub: ( + options: { query: string; limit?: number; kinds?: GitHubSearchRequest["kinds"] }, + requestId?: string, + ) => + this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "github_search_request", ...address, ...options }, + responseType: "github_search_response", + }), + getForgeCheckDetails: ( + input: { + repoOwner?: string; + repoName?: string; + checkRunId?: number; + workflowRunId?: number; + changeRequestNumber?: number; + }, + requestId?: string, + ) => + this.sendNamespacedCorrelatedSessionRequest<"checkout.forge.get_check_details.response">({ + requestId, + message: { type: "checkout.forge.get_check_details.request", ...address, ...input }, + timeout: 60000, + }), + getGithubCheckDetails: ( + input: { + repoOwner?: string; + repoName?: string; + checkRunId?: number; + workflowRunId?: number; + }, + requestId?: string, + ) => + this.sendNamespacedCorrelatedSessionRequest<"checkout.github.get_check_details.response">({ + requestId, + message: { type: "checkout.github.get_check_details.request", ...address, ...input }, + }), + }; + } + async getCheckoutStatus( cwd: string, options?: { requestId?: string }, ): Promise { - const requestId = options?.requestId; + return this.requestCheckoutStatus(cwd, options); + } + + private async requestCheckoutStatus( + cwd: string, + options?: { requestId?: string; workspaceId?: string }, + ): Promise { + const { requestId, workspaceId } = options ?? {}; + const cacheKey = workspaceId ? `${workspaceId}\0${cwd}` : cwd; if (!requestId) { - const existing = this.checkoutStatusInFlight.get(cwd); + const existing = this.checkoutStatusInFlight.get(cacheKey); if (existing) { return existing; } @@ -3538,6 +3805,7 @@ export class DaemonClient { const message = SessionInboundMessageSchema.parse({ type: "checkout_status_request", cwd, + ...(workspaceId ? { workspaceId } : {}), requestId: resolvedRequestId, }); @@ -3557,11 +3825,11 @@ export class DaemonClient { }); if (!requestId) { - this.checkoutStatusInFlight.set(cwd, responsePromise); + this.checkoutStatusInFlight.set(cacheKey, responsePromise); void responsePromise .finally(() => { - if (this.checkoutStatusInFlight.get(cwd) === responsePromise) { - this.checkoutStatusInFlight.delete(cwd); + if (this.checkoutStatusInFlight.get(cacheKey) === responsePromise) { + this.checkoutStatusInFlight.delete(cacheKey); } }) .catch(() => undefined); @@ -3570,6 +3838,31 @@ export class DaemonClient { return responsePromise; } + private async getBoundCheckoutDiff(input: { + workspaceId: string; + cwd: string; + compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }; + requestId?: string; + }): Promise { + const subscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`; + try { + const payload = await this.subscribeCheckoutDiff(input.cwd, input.compare, { + subscriptionId, + requestId: input.requestId, + workspaceId: input.workspaceId, + }); + return { + cwd: payload.cwd, + files: payload.files, + error: payload.error, + diffTooLarge: payload.diffTooLarge, + requestId: payload.requestId, + }; + } finally { + this.unsubscribeCheckoutDiff(subscriptionId); + } + } + private normalizeCheckoutDiffCompare(compare: { mode: "uncommitted" | "base"; baseRef?: string; @@ -3621,7 +3914,7 @@ export class DaemonClient { async subscribeCheckoutDiff( cwd: string, compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, - options?: { subscriptionId?: string; requestId?: string }, + options?: { subscriptionId?: string; requestId?: string; workspaceId?: string }, ): Promise { const subscriptionId = options?.subscriptionId ?? crypto.randomUUID(); const normalizedCompare = this.normalizeCheckoutDiffCompare(compare); @@ -3629,6 +3922,7 @@ export class DaemonClient { this.checkoutDiffSubscriptions.set(subscriptionId, { cwd, compare: normalizedCompare, + workspaceId: options?.workspaceId, }); const resolvedRequestId = this.createRequestId(options?.requestId); @@ -3636,6 +3930,7 @@ export class DaemonClient { type: "subscribe_checkout_diff_request", subscriptionId, cwd, + ...(options?.workspaceId ? { workspaceId: options.workspaceId } : {}), compare: normalizedCompare, requestId: resolvedRequestId, }); @@ -4083,6 +4378,7 @@ export class DaemonClient { async createWorkspace( input: { source: WorkspaceCreateRequest["source"]; + runtimeId?: string; title?: string; firstAgentContext?: WorkspaceCreateRequest["firstAgentContext"]; }, @@ -4093,6 +4389,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 } @@ -4206,6 +4503,7 @@ export class DaemonClient { mode: "list" | "file", requestId?: string, acceptBinary = false, + workspaceId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, @@ -4215,6 +4513,7 @@ export class DaemonClient { path, mode, ...(acceptBinary ? { acceptBinary: true } : {}), + ...(workspaceId ? { workspaceId } : {}), }, responseType: "file_explorer_response", }); @@ -4224,8 +4523,16 @@ export class DaemonClient { cwd: string, path: string, requestId?: string, + workspaceId?: string, ): Promise { - const payload = await this.requestFileExplorer(cwd, path, "list", requestId); + const payload = await this.requestFileExplorer( + cwd, + path, + "list", + requestId, + false, + workspaceId, + ); if (payload.error) { throw new Error(payload.error); } @@ -4235,11 +4542,23 @@ export class DaemonClient { return payload.directory; } - async readFile(cwd: string, path: string, requestId?: string): Promise { + async readFile( + cwd: string, + path: string, + requestId?: string, + workspaceId?: string, + ): Promise { const resolvedRequestId = this.createRequestId(requestId); this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path }); try { - const payload = await this.requestFileExplorer(cwd, path, "file", resolvedRequestId, true); + const payload = await this.requestFileExplorer( + cwd, + path, + "file", + resolvedRequestId, + true, + workspaceId, + ); if (payload.error) { throw new Error(payload.error); } @@ -4259,7 +4578,7 @@ export class DaemonClient { } async subscribeFile( - input: { cwd: string; path: string }, + input: { cwd: string; path: string; workspaceId?: string }, onUpdate: (version: FileVersion) => void, ): Promise<{ initial: FileVersion; unsubscribe: () => void }> { const subscriptionId = this.createRequestId(); @@ -4271,6 +4590,7 @@ export class DaemonClient { cwd: input.cwd, path: input.path, subscriptionId, + ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), }, responseType: "fs.file.subscribe.response", }); @@ -4296,6 +4616,7 @@ export class DaemonClient { content: string; expectedModifiedAt: string; expectedRevision?: string; + workspaceId?: string; }): Promise { const payload = await this.sendCorrelatedSessionRequest({ message: { type: "fs.file.write.request", ...input }, @@ -4345,10 +4666,15 @@ export class DaemonClient { async checkoutDiscardChanges( cwd: string, - input: { paths: string[] }, + input: { paths: string[]; workspaceId?: string }, ): Promise> { return this.sendNamespacedCorrelatedSessionRequest<"checkout.discard_changes.response">({ - message: { type: "checkout.discard_changes.request", cwd, paths: input.paths }, + message: { + type: "checkout.discard_changes.request", + cwd, + workspaceId: input.workspaceId, + paths: input.paths, + }, }); } @@ -4412,6 +4738,7 @@ export class DaemonClient { cwd: string, path: string, requestId?: string, + workspaceId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, @@ -4419,6 +4746,7 @@ export class DaemonClient { type: "file_download_token_request", cwd, path, + ...(workspaceId ? { workspaceId } : {}), }, responseType: "file_download_token_response", }); @@ -4427,12 +4755,14 @@ export class DaemonClient { async requestProjectIcon( cwd: string, requestId?: string, + workspaceId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, message: { type: "project_icon_request", cwd, + ...(workspaceId ? { workspaceId } : {}), }, responseType: "project_icon_response", }); @@ -4515,6 +4845,7 @@ export class DaemonClient { async getProvidersSnapshot(options?: { cwd?: string; + workspaceId?: string; ifNoneMatch?: string; requestId?: string; }): Promise { @@ -4523,6 +4854,7 @@ export class DaemonClient { message: { type: "get_providers_snapshot_request", cwd: options?.cwd, + workspaceId: options?.workspaceId, ifNoneMatch: options?.ifNoneMatch, }, responseType: "get_providers_snapshot_response", @@ -4654,6 +4986,7 @@ export class DaemonClient { async refreshProvidersSnapshot(options?: { cwd?: string; + workspaceId?: string; providers?: AgentProvider[]; requestId?: string; }): Promise { @@ -4662,6 +4995,7 @@ export class DaemonClient { message: { type: "refresh_providers_snapshot_request", cwd: options?.cwd, + workspaceId: options?.workspaceId, providers: options?.providers, }, responseType: "refresh_providers_snapshot_response", 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/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 7ff2bee920..9522ccc270 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import { createServer } from "node:http"; import net from "node:net"; @@ -10,6 +10,7 @@ import process from "node:process"; import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { experimental_createMCPClient } from "ai"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { chromium } from "playwright"; import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs"; @@ -180,7 +181,13 @@ async function waitForDesktopStatus(page) { if (typeof window.paseoDesktop?.invoke !== "function") return null; return await window.paseoDesktop.invoke("desktop_daemon_status"); }); - if (typeof status?.serverId === "string") return status; + if ( + status?.status === "running" && + status.desktopManaged === true && + typeof status.serverId === "string" + ) { + return status; + } } catch (error) { // Metro may replace the renderer execution context during its initial load. lastError = error; @@ -192,6 +199,68 @@ async function waitForDesktopStatus(page) { ); } +function processDescendants(parentPid) { + const rows = spawnSync("ps", ["-axo", "pid=,ppid=,command="], { encoding: "utf8" }) + .stdout.trim() + .split("\n") + .map((line) => { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/); + return match + ? { pid: Number(match[1]), parentPid: Number(match[2]), command: match[3] } + : null; + }) + .filter(Boolean); + const descendants = []; + const pending = [parentPid]; + while (pending.length > 0) { + const current = pending.pop(); + for (const row of rows) { + if (row.parentPid !== current) continue; + descendants.push(row); + pending.push(row.pid); + } + } + return descendants; +} + +async function proveManagedLocalProbe(status, daemonPort) { + assert(status.desktopManaged === true, "Electron did not start its managed daemon"); + assert(Number.isInteger(status.pid), "Desktop daemon status did not expose its PID"); + const daemonClient = new DaemonClient({ + url: `ws://127.0.0.1:${daemonPort}/ws`, + clientId: "desktop-managed-local-probe-e2e", + appVersion: "0.3.1", + reconnect: { enabled: false }, + }); + try { + await daemonClient.connect(); + const ensured = await Promise.race([ + daemonClient.ensureWorkspaceRuntimeProbe({ + projectId: `project-${workspaceIds[0]}`, + runtimeId: "local", + }), + delay(10_000).then(() => { + throw new Error("Electron-managed explicit Local provider probe exceeded 10 seconds"); + }), + ]); + assert( + ensured.status === "ready" && typeof ensured.workspaceId === "string", + `Explicit Local provider probe failed: ${ensured.error ?? ensured.status}`, + ); + await delay(250); + const helpers = processDescendants(status.pid).filter((processInfo) => + processInfo.command.includes("workspace-helper/executable.mjs"), + ); + assert( + helpers.length === 0, + `Explicit Local provider probe left Electron helper children: ${helpers.map(({ pid }) => pid).join(", ")}`, + ); + return ensured.workspaceId; + } finally { + await daemonClient.close().catch(() => undefined); + } +} + async function startTargetPage() { const server = createServer((_request, response) => { response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); @@ -813,6 +882,7 @@ async function main() { const children = []; let browser = null; let client = null; + let page = null; try { const commonEnv = { @@ -827,16 +897,6 @@ async function main() { FORCE_COLOR: "0", NO_COLOR: "1", }; - const daemon = spawnLogged( - "daemon", - process.execPath, - ["--import", "tsx", path.join(rootDir, "packages/server/scripts/dev-runner.ts")], - { cwd: rootDir, env: { ...commonEnv, PASEO_NODE_ENV: "development" } }, - artifactDir, - ); - children.push(daemon.child); - await waitForPort(daemonPort, "daemon", daemon); - const desktopArgs = [ process.execPath, devRunner, @@ -868,8 +928,10 @@ async function main() { await waitForPort(cdpPort, "Electron CDP", desktop); browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`); - const page = await waitForAppPage(browser, expoPort); + page = await waitForAppPage(browser, expoPort); const status = await waitForDesktopStatus(page); + await waitForPort(daemonPort, "Desktop-managed daemon"); + const localProbeWorkspaceId = await proveManagedLocalProbe(status, daemonPort); await runAppearanceFontSizeRegression(page); @@ -888,7 +950,7 @@ async function main() { callerAgentId, artifactDir, }); - writeJson(path.join(artifactDir, "result.json"), report); + writeJson(path.join(artifactDir, "result.json"), { ...report, localProbeWorkspaceId }); console.log( `Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`, ); @@ -898,6 +960,9 @@ async function main() { throw error; } finally { await client?.close().catch(() => undefined); + await page + ?.evaluate(() => window.paseoDesktop?.invoke("stop_desktop_daemon", { reason: "quit" })) + .catch(() => undefined); await browser?.close().catch(() => undefined); for (const child of children.toReversed()) stopProcess(child); await closeServer(target.server); 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..0cb527706b --- /dev/null +++ b/packages/desktop/e2e/new-workspace-runtime.spec.ts @@ -0,0 +1,151 @@ +import { test as base } from "../../app/e2e/support/fixtures"; +import { + createWorkspaceInSelectedRuntime, + expectNoProbeInWorkspaceProjection, + expectProbeFailureWithRetry, + expectProbeSkippedProjectSetup, + expectProviderAvailable, + expectFixtureProviderUnavailable, + expectHostWorkspaceAffordances, + expectRuntimeChoices, + expectRuntimeSelected, + expectSelectedHostRuntimePlacement, + expectUserWorkspaceRanProjectSetup, + expectWorkspaceOpenInRuntime, + gotoNewWorkspaceForRuntime, + seedGitProjectForRuntime, + seedNonGitProjectForRuntime, + selectRuntime, + retryFailedProbe, + 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 hostOpenTargets = [ + { + id: "vscode", + label: "VS Code", + kind: "editor" as const, + icon: { kind: "symbol" as const, name: "terminal" as const }, + }, + { + id: "finder", + label: "Finder", + kind: "file-manager" as const, + icon: { kind: "symbol" as const, name: "folder" as const }, + }, +]; + +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("probes and creates a workspace in a selected runtime", async ({ page, runtimeProject }) => { + await test.step("choose the project and runtime", async () => { + await installDesktopRuntime(page, { + serverId: getServerId(), + manageBuiltInDaemon: false, + editorTargets: hostOpenTargets, + }); + await gotoNewWorkspaceForRuntime(page, runtimeProject); + await expectRuntimeChoices(page, ["Local", "Worktree", "Fixture", "Fixture Failure"]); + await selectRuntime(page, "Fixture"); + }); + + await test.step("show the selected runtime's provider truth", async () => { + await expectProviderAvailable(page, "Fixture Agent"); + await expectProbeSkippedProjectSetup(runtimeProject); + await selectRuntime(page, "Local"); + await expectFixtureProviderUnavailable(page); + await selectRuntime(page, "Fixture"); + await expectProviderAvailable(page, "Fixture Agent"); + }); + + await test.step("create and open the workspace", async () => { + await createWorkspaceInSelectedRuntime(page); + await expectWorkspaceOpenInRuntime(page, runtimeProject, "fixture"); + await expectUserWorkspaceRanProjectSetup(runtimeProject); + await expectNoProbeInWorkspaceProjection(page, runtimeProject); + }); + + await test.step("remember the selected runtime", async () => { + await openGlobalNewWorkspaceComposer(page); + await selectNewWorkspaceProject(page, runtimeProject); + await expectRuntimeSelected(page, "Fixture"); + }); +}); + +test("shows probe failure with retry without host providers", async ({ page, runtimeProject }) => { + await test.step("choose the failing runtime", async () => { + await installDesktopRuntime(page, { serverId: getServerId(), manageBuiltInDaemon: false }); + await gotoNewWorkspaceForRuntime(page, runtimeProject); + await selectRuntime(page, "Fixture Failure"); + }); + + await test.step("show the runtime error without provider fallback", async () => { + await expectProbeFailureWithRetry(page, "Fixture probe creation failed"); + }); + + await test.step("retry the failed probe", async () => { + await retryFailedProbe(page); + await expectProbeFailureWithRetry(page, "Fixture probe creation failed"); + }); +}); + +test("creates Local and Worktree through explicit runtime selection", async ({ + page, + runtimeProject, +}) => { + await test.step("create the selected Local workspace without project setup", async () => { + await installDesktopRuntime(page, { + serverId: getServerId(), + manageBuiltInDaemon: false, + editorTargets: hostOpenTargets, + }); + await gotoNewWorkspaceForRuntime(page, runtimeProject); + await selectRuntime(page, "Local"); + await createWorkspaceInSelectedRuntime(page); + await expectWorkspaceOpenInRuntime(page, runtimeProject, "local"); + await expectSelectedHostRuntimePlacement(runtimeProject, "local", false); + await expectHostWorkspaceAffordances(page); + }); + + await test.step("create the selected Worktree workspace with project setup", async () => { + await openGlobalNewWorkspaceComposer(page); + await selectNewWorkspaceProject(page, runtimeProject); + await selectRuntime(page, "Worktree"); + await createWorkspaceInSelectedRuntime(page); + await expectWorkspaceOpenInRuntime(page, runtimeProject, "worktree"); + await expectSelectedHostRuntimePlacement(runtimeProject, "worktree", true); + await expectHostWorkspaceAffordances(page); + }); +}); + +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/e2e/open-in-editor.spec.ts b/packages/desktop/e2e/open-in-editor.spec.ts index 655155d280..d8d94a50c0 100644 --- a/packages/desktop/e2e/open-in-editor.spec.ts +++ b/packages/desktop/e2e/open-in-editor.spec.ts @@ -1,6 +1,6 @@ import { readFile, rm } from "node:fs/promises"; import { expect, test, type Page } from "../../app/e2e/support/fixtures"; -import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app"; +import { openSettings } from "../../app/e2e/support/helpers/app"; import { installDesktopRuntime } from "./support/runtime"; import { clickSettingsBackToWorkspace } from "../../app/e2e/support/helpers/settings"; @@ -119,7 +119,6 @@ test.describe("Workspace open in editor", () => { }); const recordsAfterReturnOpen = (await readEditorOpenRecords(recordPath)).length; - await gotoAppShell(page); await workspace.navigateTo(); await page.getByTestId("workspace-open-in-editor-primary").click(); await expectEditorOpened({ diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 5fd025bdbd..027d11ad21 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -18,8 +18,12 @@ "build": "npm --prefix ../.. run build:server:clean && npm run build:main && electron-builder --config electron-builder.yml", "build:main": "tsc -p tsconfig.json", "capture-harness": "./capture-harness/run.sh", + "dev:prerequisites": "npm --prefix ../.. run build:server-deps && npm --prefix ../.. run build --workspace=@getpaseo/server", + "predev": "npm run dev:prerequisites", "dev": "./scripts/dev.sh", + "predev:win": "npm run dev:prerequisites", "dev:win": "powershell ./scripts/dev.ps1", + "pretest:e2e:renderer": "npm --prefix ../.. run build:workspace-runtime-fixture", "test:e2e:renderer": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --config=playwright.config.ts --project=desktop", "test:e2e:browser-tabs": "npm run build:main && node ./e2e/browser-tabs.e2e.mjs", "verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs", diff --git a/packages/desktop/playwright.config.ts b/packages/desktop/playwright.config.ts index 3e74d1a5d0..b97057c230 100644 --- a/packages/desktop/playwright.config.ts +++ b/packages/desktop/playwright.config.ts @@ -2,7 +2,6 @@ import { defineConfig, devices } from "@playwright/test"; const baseURL = process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`; - export default defineConfig({ testDir: "./e2e", testMatch: ["**/*.spec.ts"], diff --git a/packages/desktop/scripts/dev-build-contract.test.mjs b/packages/desktop/scripts/dev-build-contract.test.mjs new file mode 100644 index 0000000000..9a30b23ef0 --- /dev/null +++ b/packages/desktop/scripts/dev-build-contract.test.mjs @@ -0,0 +1,160 @@ +import { execFile } from "node:child_process"; +import { + access, + cp, + mkdir, + mkdtemp, + readFile, + rm, + stat, + symlink, + utimes, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, test } from "vitest"; + +const desktopDir = path.resolve(import.meta.dirname, ".."); +const repoRoot = path.resolve(desktopDir, "../.."); +const execFileAsync = promisify(execFile); +const npmCommand = process.platform === "win32" ? process.execPath : "npm"; +const npmPrefixArgs = + process.platform === "win32" ? [requireEnvironmentVariable("npm_execpath")] : []; +const prerequisite = "npm run dev:prerequisites"; + +function requireEnvironmentVariable(name) { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required to invoke npm on Windows`); + return value; +} + +describe("desktop dev build contract", () => { + test("runs one prerequisite before either platform dev entry point", async () => { + const packageJson = JSON.parse(await readFile(path.join(desktopDir, "package.json"), "utf8")); + const devScript = await readFile(path.join(import.meta.dirname, "dev.sh"), "utf8"); + + expect(packageJson.scripts.predev).toBe(prerequisite); + expect(packageJson.scripts["predev:win"]).toBe(prerequisite); + expect(packageJson.scripts["dev:prerequisites"]).toBe( + "npm --prefix ../.. run build:server-deps && npm --prefix ../.. run build --workspace=@getpaseo/server", + ); + expect(packageJson.scripts.dev).toBe("./scripts/dev.sh"); + expect(packageJson.scripts["dev:win"]).toBe("powershell ./scripts/dev.ps1"); + expect(devScript.indexOf("npm run build:main")).toBeLessThan( + devScript.indexOf('exec node "$SCRIPT_DIR/dev-runner.mjs"'), + ); + }); + + test("builds the private runtime fixture before renderer acceptance", async () => { + const packageJson = JSON.parse(await readFile(path.join(desktopDir, "package.json"), "utf8")); + + expect(packageJson.scripts["pretest:e2e:renderer"]).toBe( + "npm --prefix ../.. run build:workspace-runtime-fixture", + ); + }); + + test("the public prerequisite repairs missing and stale server artifacts in an isolated checkout", async () => { + const isolatedRoot = await createIsolatedBuildCheckout(); + const staleServerExport = path.join( + isolatedRoot, + "packages/server/dist/server/server/exports.js", + ); + const staleSourceExport = path.join(isolatedRoot, "packages/server/src/server/exports.ts"); + const missingServerExport = path.join( + isolatedRoot, + "packages/server/dist/server/server/session.js", + ); + const missingSourceExport = path.join(isolatedRoot, "packages/server/src/server/session.ts"); + + await mkdir(path.dirname(staleServerExport), { recursive: true }); + await writeFile(staleServerExport, "// stale isolated artifact\n"); + const staleTime = new Date(0); + await utimes(staleServerExport, staleTime, staleTime); + + try { + await runPrerequisite(isolatedRoot); + await expect(access(missingServerExport)).resolves.toBeUndefined(); + expect(await readFile(staleServerExport, "utf8")).not.toContain("stale isolated artifact"); + expect((await stat(staleServerExport)).mtimeMs).toBeGreaterThanOrEqual( + (await stat(staleSourceExport)).mtimeMs, + ); + expect((await stat(missingServerExport)).mtimeMs).toBeGreaterThanOrEqual( + (await stat(missingSourceExport)).mtimeMs, + ); + } finally { + await rm(isolatedRoot, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); + } + }, 130_000); +}); + +async function createIsolatedBuildCheckout() { + const isolatedRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-desktop-build-contract-")); + const packageDirectories = [ + "packages/client", + "packages/desktop", + "packages/highlight", + "packages/plugin", + "packages/protocol", + "packages/relay", + "packages/server", + "packages/workspace-runtime-contract", + "packages/workspace-helper", + ]; + + try { + await Promise.all([ + ...["package.json", "package-lock.json", "tsconfig.base.json", "tsconfig.json"].map((file) => + cp(path.join(repoRoot, file), path.join(isolatedRoot, file)), + ), + cp(path.join(repoRoot, "scripts"), path.join(isolatedRoot, "scripts"), { + recursive: true, + }), + ...packageDirectories.map(async (relativeDirectory) => { + const target = path.join(isolatedRoot, relativeDirectory); + await mkdir(path.dirname(target), { recursive: true }); + await cp(path.join(repoRoot, relativeDirectory), target, { + recursive: true, + filter: (source) => + !source.endsWith(`${path.sep}dist`) && + !source.endsWith(`${path.sep}node_modules`) && + !source.endsWith(`${path.sep}test-results`), + }); + }), + ]); + await symlink(path.join(repoRoot, "node_modules"), path.join(isolatedRoot, "node_modules")); + await Promise.all( + packageDirectories.map((relativeDirectory) => + symlink( + path.join(repoRoot, relativeDirectory, "node_modules"), + path.join(isolatedRoot, relativeDirectory, "node_modules"), + ), + ), + ); + return isolatedRoot; + } catch (error) { + await rm(isolatedRoot, { recursive: true, force: true }); + throw error; + } +} + +async function runPrerequisite(isolatedRoot) { + try { + await execFileAsync( + npmCommand, + [...npmPrefixArgs, "run", "dev:prerequisites", "--workspace=@getpaseo/desktop"], + { + cwd: isolatedRoot, + timeout: 120_000, + }, + ); + } catch (error) { + throw new Error(`${error.stdout ?? ""}\n${error.stderr ?? ""}`, { cause: error }); + } +} diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts index 8eac5ecc12..66398783a9 100644 --- a/packages/desktop/src/daemon/desktop-packaging.test.ts +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -111,6 +111,8 @@ describe("desktop packaging", () => { for (const required of ["@getpaseo/cli", "@getpaseo/server"]) { expect(deps[required], `${required} must be declared in dependencies`).toBe("*"); } + expect(deps).not.toHaveProperty("@getpaseo/docker-workspace-runtime"); + expect(deps).not.toHaveProperty("@getpaseo/srt-workspace-runtime"); }); it("launches the packaged macOS CLI through Helper instead of the main app executable", () => { diff --git a/packages/protocol/src/messages.file-context-actions.test.ts b/packages/protocol/src/messages.file-context-actions.test.ts index 0032697b77..d0a1ee7c09 100644 --- a/packages/protocol/src/messages.file-context-actions.test.ts +++ b/packages/protocol/src/messages.file-context-actions.test.ts @@ -149,6 +149,7 @@ describe("file context action messages", () => { const request = { type: "checkout.discard_changes.request", cwd: "/workspace", + workspaceId: "workspace-a", paths: ["src", "README.md"], requestId: "discard-1", }; diff --git a/packages/protocol/src/messages.file-editing.test.ts b/packages/protocol/src/messages.file-editing.test.ts index 9e2c16d99b..46642516e5 100644 --- a/packages/protocol/src/messages.file-editing.test.ts +++ b/packages/protocol/src/messages.file-editing.test.ts @@ -28,8 +28,9 @@ describe("workspace file editing messages", () => { path: "file.ts", subscriptionId: "subscription-1", requestId: "request-1", - }).subscriptionId, - ).toBe("subscription-1"); + workspaceId: "workspace-1", + }), + ).toMatchObject({ subscriptionId: "subscription-1", workspaceId: "workspace-1" }); expect( FileSubscribeResponseSchema.parse({ type: "fs.file.subscribe.response", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 5c4e2e75e4..784427a395 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1,4 +1,6 @@ import { z } from "zod"; + +const OptionalNonEmptyWorkspaceIdSchema = z.string().min(1).optional(); import { TerminalActivitySchema } from "./terminal-activity.js"; import { CLIENT_CAPS } from "./client-capabilities.js"; import { AGENT_LIFECYCLE_STATUSES } from "./agent-lifecycle.js"; @@ -1275,6 +1277,7 @@ export const FetchAgentHistoryRequestMessageSchema = z.object({ export const FetchRecentProviderSessionsRequestMessageSchema = z.object({ type: z.literal("fetch_recent_provider_sessions_request"), requestId: z.string(), + workspaceId: z.string().optional(), cwd: z.string().optional(), providers: z.array(z.string()).optional(), since: z.string().optional(), @@ -1514,6 +1517,7 @@ export const ListAvailableProvidersRequestMessageSchema = z.object({ export const GetProvidersSnapshotRequestMessageSchema = z.object({ type: z.literal("get_providers_snapshot_request"), cwd: z.string().optional(), + workspaceId: z.string().optional(), // COMPAT(compactProviderSnapshots): old daemons ignore this field and return a full snapshot. ifNoneMatch: z.string().optional(), requestId: z.string(), @@ -1522,6 +1526,7 @@ export const GetProvidersSnapshotRequestMessageSchema = z.object({ export const RefreshProvidersSnapshotRequestMessageSchema = z.object({ type: z.literal("refresh_providers_snapshot_request"), cwd: z.string().optional(), + workspaceId: z.string().optional(), providers: z.array(AgentProviderSchema).optional(), requestId: z.string(), }); @@ -1899,16 +1904,21 @@ const CheckoutDiffCompareSchema = z.object({ ignoreWhitespace: z.boolean().optional(), }); +const OptionalWorkspaceGitIdSchema = z.string().trim().min(1).optional(); +const WorkspaceGitCwdSchema = z.string().trim().min(1); + export const CheckoutStatusRequestSchema = z.object({ type: z.literal("checkout_status_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, requestId: z.string(), }); export const SubscribeCheckoutDiffRequestSchema = z.object({ type: z.literal("subscribe_checkout_diff_request"), subscriptionId: z.string(), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, compare: CheckoutDiffCompareSchema, requestId: z.string(), }); @@ -1920,7 +1930,8 @@ export const UnsubscribeCheckoutDiffRequestSchema = z.object({ export const CheckoutCommitRequestSchema = z.object({ type: z.literal("checkout_commit_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, message: z.string().optional(), addAll: z.boolean().optional(), requestId: z.string(), @@ -1928,7 +1939,8 @@ export const CheckoutCommitRequestSchema = z.object({ export const CheckoutMergeRequestSchema = z.object({ type: z.literal("checkout_merge_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, baseRef: z.string().optional(), strategy: z.enum(["merge", "squash"]).optional(), requireCleanTarget: z.boolean().optional(), @@ -1937,7 +1949,8 @@ export const CheckoutMergeRequestSchema = z.object({ export const CheckoutMergeFromBaseRequestSchema = z.object({ type: z.literal("checkout_merge_from_base_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, baseRef: z.string().optional(), requireCleanTarget: z.boolean().optional(), requestId: z.string(), @@ -1945,32 +1958,37 @@ export const CheckoutMergeFromBaseRequestSchema = z.object({ export const CheckoutPullRequestSchema = z.object({ type: z.literal("checkout_pull_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, requestId: z.string(), }); export const CheckoutPushRequestSchema = z.object({ type: z.literal("checkout_push_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, requestId: z.string(), }); export const CheckoutRefreshRequestSchema = z.object({ type: z.literal("checkout.refresh.request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, requestId: z.string(), }); export const CheckoutDiscardChangesRequestSchema = z.object({ type: z.literal("checkout.discard_changes.request"), cwd: z.string(), + workspaceId: OptionalWorkspaceGitIdSchema, paths: z.array(z.string()).min(1), requestId: z.string(), }); export const CheckoutPrCreateRequestSchema = z.object({ type: z.literal("checkout_pr_create_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, title: z.string().optional(), body: z.string().optional(), baseRef: z.string().optional(), @@ -1979,14 +1997,16 @@ export const CheckoutPrCreateRequestSchema = z.object({ export const CheckoutPrMergeRequestSchema = z.object({ type: z.literal("checkout_pr_merge_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, mergeMethod: z.enum(["merge", "squash", "rebase"]), requestId: z.string(), }); export const CheckoutForgeSetAutoMergeRequestSchema = z.object({ type: z.literal("checkout.forge.set_auto_merge.request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, enabled: z.boolean(), mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(), requestId: z.string(), @@ -1996,7 +2016,8 @@ export const CheckoutForgeSetAutoMergeRequestSchema = z.object({ // all supported clients use checkout.forge.set_auto_merge.*. export const CheckoutGithubSetAutoMergeRequestSchema = z.object({ type: z.literal("checkout.github.set_auto_merge.request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, enabled: z.boolean(), mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(), requestId: z.string(), @@ -2023,13 +2044,15 @@ const CheckoutCommitSchema = z.object({ export const CheckoutCommitsListRequestSchema = z.object({ type: z.literal("checkout.commits.list.request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, requestId: z.string(), }); export const CheckoutCommitFileDiffRequestSchema = z.object({ type: z.literal("checkout.commits.file_diff.request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, sha: z.string(), path: z.string(), requestId: z.string(), @@ -2038,7 +2061,8 @@ export const CheckoutCommitFileDiffRequestSchema = z.object({ const GitHubRepoSegmentSchema = z.string().regex(/^[A-Za-z0-9._-]+$/); const CheckoutCheckDetailsRequestPayloadSchema = z.object({ - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, // GitHub addresses check runs by owner/name. GitLab resolves the project from // cwd and omits these GitHub-only single-segment fields. repoOwner: GitHubRepoSegmentSchema.optional(), @@ -2071,13 +2095,15 @@ export const CheckoutGithubGetCheckDetailsRequestSchema = export const CheckoutPrStatusRequestSchema = z.object({ type: z.literal("checkout_pr_status_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, requestId: z.string(), }); export const PullRequestTimelineRequestSchema = z.object({ type: z.literal("pull_request_timeline_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, prNumber: z.number(), repoOwner: z.string(), repoName: z.string(), @@ -2086,28 +2112,32 @@ export const PullRequestTimelineRequestSchema = z.object({ export const ValidateBranchRequestSchema = z.object({ type: z.literal("validate_branch_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, branchName: z.string(), requestId: z.string(), }); export const CheckoutSwitchBranchRequestSchema = z.object({ type: z.literal("checkout_switch_branch_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, branch: z.string(), requestId: z.string(), }); export const CheckoutRenameBranchRequestSchema = z.object({ type: z.literal("checkout.rename_branch.request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, branch: z.string(), requestId: z.string(), }); export const StashSaveRequestSchema = z.object({ type: z.literal("stash_save_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, /** Branch name to tag the stash with for later identification. */ branch: z.string().optional(), requestId: z.string(), @@ -2115,7 +2145,8 @@ export const StashSaveRequestSchema = z.object({ export const StashPopRequestSchema = z.object({ type: z.literal("stash_pop_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, /** Zero-based index from stash_list_response. */ stashIndex: z.number().int().min(0), requestId: z.string(), @@ -2123,7 +2154,8 @@ export const StashPopRequestSchema = z.object({ export const StashListRequestSchema = z.object({ type: z.literal("stash_list_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, /** If true, only return paseo-created stashes. Default true. */ paseoOnly: z.boolean().optional(), requestId: z.string(), @@ -2131,7 +2163,8 @@ export const StashListRequestSchema = z.object({ export const BranchSuggestionsRequestSchema = z.object({ type: z.literal("branch_suggestions_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, query: z.string().optional(), limit: z.number().int().min(1).max(200).optional(), requestId: z.string(), @@ -2170,7 +2203,8 @@ export const GitHubSearchKindSchema = ForgeSearchKindSchema; export const ForgeSearchRequestSchema = z.object({ type: z.literal("forge.search.request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, query: z.string(), limit: z.number().int().min(1).max(50).optional(), kinds: z.array(ForgeSearchKindSchema).optional(), @@ -2181,7 +2215,8 @@ export const ForgeSearchRequestSchema = z.object({ // clients use forge.search.*. export const GitHubSearchRequestSchema = z.object({ type: z.literal("github_search_request"), - cwd: z.string(), + cwd: WorkspaceGitCwdSchema, + workspaceId: OptionalWorkspaceGitIdSchema, query: z.string(), limit: z.number().int().min(1).max(50).optional(), kinds: z.array(GitHubSearchKindSchema).optional(), @@ -2215,7 +2250,7 @@ export const PaseoWorktreeArchiveRequestSchema = z.object({ // Explicit workspace record to archive. A directory can back multiple workspaces // (Model B), so resolving the target by cwd alone picks the wrong record. When // present the daemon archives this exact workspace; when absent it falls back to - // resolving by worktreePath, preferring the worktree-kind record on a cwd tie. + // resolving by worktreePath only when it identifies one workspace unambiguously. workspaceId: z.string().optional(), // COMPAT(worktreeArchiveScope): added in v0.1.97, drop the gate when floor >= v0.1.97. // Scope of the archive operation. "workspace" archives a single workspace record @@ -2329,12 +2364,32 @@ 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(), +}); + +export const WorkspaceRuntimeEnsureProbeRequestSchema = z.object({ + type: z.literal("workspace.runtime.ensure_probe.request"), + projectId: z.string(), + runtimeId: z.string(), + 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. @@ -2436,6 +2491,7 @@ export const FileExplorerRequestSchema = z.object({ mode: z.enum(["list", "file"]), requestId: z.string(), acceptBinary: z.boolean().optional(), + workspaceId: z.string().optional(), }); export const FileVersionSchema = z.discriminatedUnion("status", [ @@ -2466,6 +2522,7 @@ export const FileSubscribeRequestSchema = z.object({ path: z.string(), subscriptionId: z.string(), requestId: z.string(), + workspaceId: z.string().optional(), }); export const FileUnsubscribeRequestSchema = z.object({ @@ -2482,6 +2539,7 @@ export const FileWriteRequestSchema = z.object({ expectedModifiedAt: z.string(), expectedRevision: z.string().optional(), requestId: z.string(), + workspaceId: z.string().optional(), }); export const FileEntryCreateRequestSchema = z.object({ @@ -2519,6 +2577,7 @@ export const ProjectIconRequestSchema = z.object({ type: z.literal("project_icon_request"), cwd: z.string(), requestId: z.string(), + workspaceId: z.string().optional(), }); export const ProjectIconGetRequestSchema = z.object({ @@ -2532,6 +2591,7 @@ export const FileDownloadTokenRequestSchema = z.object({ cwd: z.string(), path: z.string(), requestId: z.string(), + workspaceId: z.string().optional(), }); export const FileUploadRequestSchema = z.object({ @@ -2943,6 +3003,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ProjectGithubCloneRequestSchema, ArchiveWorkspaceRequestSchema, WorkspaceCreateRequestSchema, + WorkspaceRuntimeListRequestSchema, + WorkspaceRuntimeEnsureProbeRequestSchema, WorkspaceClearAttentionRequestSchema, FileExplorerRequestSchema, FileSubscribeRequestSchema, @@ -3206,6 +3268,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. @@ -3558,6 +3622,9 @@ export const WorkspaceDescriptorPayloadSchema = z projectCustomIconRevision: z.string().nullable().optional(), projectRootPath: z.string(), workspaceDirectory: z.string().optional(), + // Explicit capability for host editor/file-manager integrations. Runtime-local cwd is not enough. + // COMPAT(host-visible-path): added in v0.3.1; older daemons omit this capability. + hostVisiblePath: z.string().optional(), // COMPAT(worktreeSlug): added in v0.2.6, remove optional after 2027-01-31. // Present only for Paseo-owned worktrees; this is the basename of their root directory. worktreeSlug: z.string().optional(), @@ -4235,6 +4302,24 @@ 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 WorkspaceRuntimeEnsureProbeResponseSchema = z.object({ + type: z.literal("workspace.runtime.ensure_probe.response"), + payload: z.object({ + workspaceId: z.string().nullable(), + status: z.enum(["ready", "error"]), + error: z.string().nullable(), + requestId: z.string(), + }), +}); + export const WorkspaceClearAttentionResponseSchema = z.object({ type: z.literal("workspace.clear_attention.response"), payload: z.object({ @@ -4496,6 +4581,7 @@ const AheadBehindSchema = z.object({ }); const CheckoutStatusCommonSchema = z.object({ + workspaceId: OptionalNonEmptyWorkspaceIdSchema, cwd: z.string(), error: CheckoutErrorSchema.nullable(), requestId: z.string(), @@ -4697,6 +4783,7 @@ export const CheckoutStatusUpdateSchema = z.object({ const CheckoutDiffSubscriptionPayloadSchema = z.object({ subscriptionId: z.string(), + workspaceId: OptionalNonEmptyWorkspaceIdSchema, cwd: z.string(), files: z.array(ParsedDiffFileSchema), error: CheckoutErrorSchema.nullable(), @@ -5466,6 +5553,7 @@ export const ProvidersSnapshotUpdateMessageSchema = z.object({ type: z.literal("providers_snapshot_update"), payload: z.object({ cwd: z.string().optional(), + workspaceId: z.string().optional(), entries: z.array(ProviderSnapshotEntrySchema), compactSnapshot: CompactProviderSnapshotSchema.optional(), snapshotHash: z.string().optional(), @@ -5964,6 +6052,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CancelAgentResponseMessageSchema, ClearAgentAttentionResponseMessageSchema, WorkspaceCreateResponseSchema, + WorkspaceRuntimeListResponseSchema, + WorkspaceRuntimeEnsureProbeResponseSchema, WorkspaceClearAttentionResponseSchema, SendAgentMessageResponseMessageSchema, SetVoiceModeResponseMessageSchema, @@ -6199,6 +6289,13 @@ 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 WorkspaceRuntimeEnsureProbePayload = z.infer< + typeof WorkspaceRuntimeEnsureProbeResponseSchema +>["payload"]; export type ProjectRenameResponsePayload = z.infer; export type ProjectRemoveResponsePayload = z.infer; export type WaitForFinishResponseMessage = z.infer; diff --git a/packages/protocol/src/messages.workspace-git-address.test.ts b/packages/protocol/src/messages.workspace-git-address.test.ts new file mode 100644 index 0000000000..8cdd50d0f0 --- /dev/null +++ b/packages/protocol/src/messages.workspace-git-address.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "vitest"; +import { CheckoutForgeGetCheckDetailsRequestSchema, CheckoutStatusRequestSchema } from "./messages"; + +test.each(["", " "])( + "Git request schemas accept omitted legacy identity but reject selected identity %j", + (workspaceId) => { + expect( + CheckoutStatusRequestSchema.safeParse({ + type: "checkout_status_request", + cwd: "/legacy", + requestId: "legacy", + }).success, + ).toBe(true); + expect( + CheckoutStatusRequestSchema.safeParse({ + type: "checkout_status_request", + workspaceId, + cwd: "/shared", + requestId: "selected", + }).success, + ).toBe(false); + expect( + CheckoutForgeGetCheckDetailsRequestSchema.safeParse({ + type: "checkout.forge.get_check_details.request", + workspaceId, + cwd: "/shared", + checkRunId: 1, + requestId: "check", + }).success, + ).toBe(false); + }, +); + +test.each(["", " "])("selected Git request schemas reject cwd %j", (cwd) => { + expect( + CheckoutStatusRequestSchema.safeParse({ + type: "checkout_status_request", + workspaceId: "workspace-a", + cwd, + requestId: "selected", + }).success, + ).toBe(false); +}); + +test("selected Git request schemas normalize a valid cwd", () => { + expect( + CheckoutStatusRequestSchema.parse({ + type: "checkout_status_request", + workspaceId: " workspace-a ", + cwd: " /shared ", + requestId: "selected", + }), + ).toMatchObject({ workspaceId: "workspace-a", cwd: "/shared" }); +}); diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index bdf985152d..aaceaafd6f 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -5,6 +5,10 @@ import { SessionInboundMessageSchema, SessionOutboundMessageSchema, WorkspaceCreateRequestSchema, + WorkspaceRuntimeListRequestSchema, + WorkspaceRuntimeListResponseSchema, + WorkspaceRuntimeEnsureProbeRequestSchema, + WorkspaceRuntimeEnsureProbeResponseSchema, WorkspaceDescriptorPayloadSchema, WorkspaceScriptPayloadSchema, } from "./messages.js"; @@ -1171,4 +1175,88 @@ 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"); + }); + + test("workspace runtime probe ensure has explicit ready and error responses", () => { + expect( + WorkspaceRuntimeEnsureProbeRequestSchema.parse({ + type: "workspace.runtime.ensure_probe.request", + projectId: "project-1", + runtimeId: "fixture", + requestId: "probe-1", + }), + ).toEqual({ + type: "workspace.runtime.ensure_probe.request", + projectId: "project-1", + runtimeId: "fixture", + requestId: "probe-1", + }); + expect( + WorkspaceRuntimeEnsureProbeResponseSchema.parse({ + type: "workspace.runtime.ensure_probe.response", + payload: { + workspaceId: "probe-123", + status: "ready", + error: null, + requestId: "probe-1", + }, + }).payload, + ).toEqual({ + workspaceId: "probe-123", + status: "ready", + error: null, + requestId: "probe-1", + }); + expect( + WorkspaceRuntimeEnsureProbeResponseSchema.parse({ + type: "workspace.runtime.ensure_probe.response", + payload: { + workspaceId: null, + status: "error", + error: "fixture failed", + requestId: "probe-2", + }, + }).payload, + ).toMatchObject({ status: "error", error: "fixture failed" }); + }); }); diff --git a/packages/server/package.json b/packages/server/package.json index 429056e14d..52af6a7f37 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -6,6 +6,7 @@ "dist/server", "dist/src", "dist/scripts", + "!dist/server/server/workspace-helper", "!dist/**/*.map", "README.md", ".env.example" @@ -52,8 +53,9 @@ "speech:download": "tsx scripts/download-speech-models.ts", "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", - "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"", + "pretest:unit": "npm --prefix ../.. run build:workspace-runtime-fixture", + "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\" --exclude \"src/server/workspace-git-service.runtime.integration.test.ts\" --exclude \"src/server/workspace-runtime/workspace-runtime.command.test.ts\"", + "test:integration": "vitest run --maxWorkers=1 src/server/workspace-git-service.runtime.integration.test.ts src/server/workspace-runtime/workspace-runtime.command.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", @@ -76,6 +78,8 @@ "@getpaseo/highlight": "0.4.0", "@getpaseo/protocol": "0.4.0", "@getpaseo/relay": "0.4.0", + "@getpaseo/workspace-helper": "0.4.0", + "@getpaseo/workspace-runtime-contract": "0.4.0", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/server/src/server/agent/agent-loading.test.ts b/packages/server/src/server/agent/agent-loading.test.ts index e4943a079d..6619dc9404 100644 --- a/packages/server/src/server/agent/agent-loading.test.ts +++ b/packages/server/src/server/agent/agent-loading.test.ts @@ -16,6 +16,7 @@ import type { AgentSessionConfig, } from "./agent-sdk-types.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; +import { resolveHostProviderWorkspace } from "../test-utils/provider-workspace-stub.js"; test("loads archived records for history and active records with the interactive default", async () => { const root = await mkdtemp(path.join(tmpdir(), "agent-loading-purpose-")); @@ -50,6 +51,7 @@ test("loads archived records for history and active records with the interactive clients: { codex: client }, registry: storage, logger, + resolveProviderWorkspace: resolveHostProviderWorkspace, }); const archivedId = "00000000-0000-4000-8000-000000000301"; diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 821828f4ab..d48e0dcc8e 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { createTestLogger } from "../../test-utils/test-logger.js"; +import { resolveHostProviderWorkspace } from "../test-utils/provider-workspace-stub.js"; import { AgentManager, AgentManagerShuttingDownError, @@ -34,9 +35,11 @@ import type { AgentSlashCommand, AgentStreamEvent, AgentTimelineItem, + FetchCatalogOptions, ImportProviderSessionInput, ImportProviderSessionContext, ResolveAgentDefaultModeInput, + ProviderWorkspace, } from "./agent-sdk-types.js"; import type { PaseoToolCatalog } from "./tools/types.js"; import type { ProviderDefinition } from "./provider-registry.js"; @@ -50,6 +53,49 @@ interface Deferred { reject: (reason?: unknown) => void; } +function createTestProviderWorkspace(commandAvailable: boolean): ProviderWorkspace { + return { + cwd: ".", + async resolveExecutable(command) { + if (!commandAvailable) throw new Error(`Provider command '${command}' was not found`); + return "/provider"; + }, + async launch() { + throw new Error("not used"); + }, + launchDeferred() { + throw new Error("not used"); + }, + async runProbe() { + throw new Error("not used"); + }, + async readWorkspaceText() { + throw new Error("not used"); + }, + async writeWorkspaceText() { + throw new Error("not used"); + }, + async listState() { + return { entries: [] }; + }, + async readStateText() { + throw new Error("not used"); + }, + async findStateFile() { + return null; + }, + async materializeStateFile() { + throw new Error("not used"); + }, + async removeStateFile() { + throw new Error("not used"); + }, + allowsHostService() { + return false; + }, + }; +} + function deferred(): Deferred { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -1807,6 +1853,135 @@ test("createAgent passes daemon launch env through the provider launch context", }); }); +test("selected workspace availability fails closed without probing or launching on the host", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-workspace-provider-")); + const availabilityScopes: Array = []; + let createCalls = 0; + class RuntimeScopedClient extends TestAgentClient { + override async isAvailable(options?: FetchCatalogOptions): Promise { + availabilityScopes.push(options); + return false; + } + + override async createSession(config: AgentSessionConfig): Promise { + createCalls += 1; + return new TestAgentSession(config); + } + } + const manager = new AgentManager({ + clients: { codex: new RuntimeScopedClient() }, + logger, + resolveProviderWorkspace: async () => createTestProviderWorkspace(false), + }); + + try { + await expect( + manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: "selected-workspace", + }), + ).rejects.toThrow("not available in the selected workspace runtime"); + expect(availabilityScopes).toEqual([ + expect.objectContaining({ + scope: "workspace", + workspaceId: "selected-workspace", + }), + ]); + expect(createCalls).toBe(0); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("host-placed workspaces require host provider availability before create and resume", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-host-workspace-provider-")); + const availabilityScopes: Array = []; + let createCalls = 0; + let resumeCalls = 0; + class UnavailableHostClient extends TestAgentClient { + override async isAvailable(options?: FetchCatalogOptions): Promise { + availabilityScopes.push(options); + return false; + } + + override async createSession(config: AgentSessionConfig): Promise { + createCalls += 1; + return new TestAgentSession(config); + } + + override async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + ): Promise { + resumeCalls += 1; + return await super.resumeSession(handle, overrides); + } + } + const manager = new AgentManager({ + clients: { codex: new UnavailableHostClient() }, + logger, + resolveProviderWorkspace: resolveHostProviderWorkspace, + }); + const handle: AgentPersistenceHandle = { + provider: "codex", + sessionId: "host-workspace-session", + metadata: { cwd: workdir }, + }; + + try { + await expect( + manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: "local-workspace", + }), + ).rejects.toThrow("Provider 'codex' is not available"); + await expect( + manager.resumeAgentFromPersistence(handle, undefined, undefined, { + workspaceId: "local-workspace", + }), + ).rejects.toThrow("Provider 'codex' is not available"); + + expect(availabilityScopes.length).toBeGreaterThanOrEqual(2); + expect(availabilityScopes.every((scope) => scope === undefined)).toBe(true); + expect(createCalls).toBe(0); + expect(resumeCalls).toBe(0); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("selected workspace without a bound capability never checks host availability", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-missing-workspace-capability-")); + let hostAvailabilityCalls = 0; + let createCalls = 0; + class HostAvailableClient extends TestAgentClient { + override async isAvailable(): Promise { + hostAvailabilityCalls += 1; + return true; + } + + override async createSession(config: AgentSessionConfig): Promise { + createCalls += 1; + return new TestAgentSession(config); + } + } + const manager = new AgentManager({ + clients: { codex: new HostAvailableClient() }, + logger, + resolveProviderWorkspace: async () => undefined, + }); + + try { + await expect( + manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: "selected-without-capability", + }), + ).rejects.toThrow("workspace runtime capability is unavailable"); + expect(hostAvailabilityCalls).toBe(0); + expect(createCalls).toBe(0); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("createAgent passes persistSession to provider create options", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); @@ -1859,6 +2034,7 @@ test("createAgent persists workspaceId on the stored record and emits it in the }, registry: storage, logger, + resolveProviderWorkspace: async () => createTestProviderWorkspace(true), idFactory: () => "00000000-0000-4000-8000-0000000000a1", }); @@ -2855,12 +3031,14 @@ test("importProviderSession imports the selected session without listing and pub } const client = new ImportClient(); + const workspace = createTestProviderWorkspace(true); const manager = new AgentManager({ clients: { codex: client, }, registry: storage, logger, + resolveProviderWorkspace: async () => workspace, }); manager.subscribe((event) => events.push(event), { replayState: false }); @@ -2879,6 +3057,7 @@ test("importProviderSession imports the selected session without listing and pub PASEO_AGENT_ID: imported.id, PASEO_AGENT_CWD: workdir, }, + workspace, }); expect(imported.lifecycle).toBe("idle"); expect(imported.historyPrimed).toBe(true); @@ -6738,6 +6917,7 @@ test("archiveAgent detaches an open same-workspace child instead of cascading", clients: { codex: new TestAgentClient() }, registry: storage, logger, + resolveProviderWorkspace: resolveHostProviderWorkspace, }); const parent = await manager.createAgent( { provider: "codex", cwd: workdir, title: "Parent" }, @@ -6774,6 +6954,7 @@ test("archiveAgent detaches a cross-workspace child even when its tab is closed" clients: { codex: new TestAgentClient() }, registry: storage, logger, + resolveProviderWorkspace: resolveHostProviderWorkspace, }); const parent = await manager.createAgent( { provider: "codex", cwd: workdir, title: "Parent" }, @@ -6827,6 +7008,7 @@ test("archiveAgent re-reads a child before deciding whether to cascade", async ( clients: { codex: new TestAgentClient() }, registry: storage, logger, + resolveProviderWorkspace: resolveHostProviderWorkspace, }); const parent = await manager.createAgent( { provider: "codex", cwd: workdir, title: "Parent" }, @@ -6878,6 +7060,7 @@ test("archiveAgent cannot overtake a received child open-tab update", async () = clients: { codex: new TestAgentClient() }, registry: storage, logger, + resolveProviderWorkspace: resolveHostProviderWorkspace, }); const parent = await manager.createAgent( { provider: "codex", cwd: workdir, title: "Parent" }, @@ -9004,6 +9187,20 @@ class RecordingPersistedAgentsClient implements AgentClient { } } +test("selected import discovery without a bound capability never reads host provider state", async () => { + const client = new RecordingPersistedAgentsClient("claude"); + const manager = new AgentManager({ + clients: { claude: client }, + logger, + resolveProviderWorkspace: async () => undefined, + }); + + await expect( + manager.listImportableSessions({ workspaceId: "selected-without-capability" }), + ).rejects.toThrow("workspace runtime capability is unavailable"); + expect(client.calls).toBe(0); +}); + test.each([ [ "disabled", diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index f3db4f385e..5d90ecdae1 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -45,6 +45,7 @@ import { type ImportedTimelineEntry, type ImportableProviderSession, type ListImportableSessionsOptions, + type ProviderWorkspace, } from "./agent-sdk-types.js"; import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js"; import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js"; @@ -131,6 +132,7 @@ interface PreparedSessionConfig { interface NormalizeConfigOptions { resolveDefaultModel?: boolean; env?: Record; + validateHostCwd?: boolean; } interface TimeoutOptions { @@ -205,6 +207,7 @@ interface HydrateTimelineOptions { } export type ImportablePersistedAgentQueryOptions = ListImportableSessionsOptions & { + workspaceId?: string; /** * When set, only providers in this set are scanned, in addition to the * built-in importable allowlist + enabled + non-derived rules. @@ -279,6 +282,10 @@ export interface AgentManagerOptions { agentStreamCoalesceWindowMs?: number; rescueTimeouts?: AgentManagerRescueTimeouts; logger: Logger; + resolveProviderWorkspace?: ( + workspaceId: string, + cwd: string, + ) => Promise; } export interface WaitForAgentOptions { @@ -666,6 +673,7 @@ export class AgentManager { private onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void; private logger: Logger; private readonly rescueTimeouts: Required; + private readonly resolveProviderWorkspace?: AgentManagerOptions["resolveProviderWorkspace"]; private acceptingAgentRegistrations = true; constructor(options: AgentManagerOptions) { @@ -679,6 +687,7 @@ export class AgentManager { this.configurePaseoTools(options); this.appendSystemPrompt = options.appendSystemPrompt ?? ""; this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); + this.resolveProviderWorkspace = options.resolveProviderWorkspace; this.rescueTimeouts = { reloadSessionCloseMs: options.rescueTimeouts?.reloadSessionCloseMs ?? RELOAD_SESSION_CLOSE_TIMEOUT_MS, @@ -886,6 +895,20 @@ export class AgentManager { async listImportableSessions( options?: ImportablePersistedAgentQueryOptions, ): Promise { + let workspace: ProviderWorkspace | undefined; + if (options?.workspaceId) { + if (!this.resolveProviderWorkspace) { + throw new Error(`workspace runtime capability is unavailable: ${options.workspaceId}`); + } + const resolved = await this.resolveProviderWorkspace( + options.workspaceId, + options.cwd ?? process.cwd(), + ); + if (resolved === undefined) { + throw new Error(`workspace runtime capability is unavailable: ${options.workspaceId}`); + } + workspace = resolved ?? undefined; + } const providerEntries = Array.from(this.clients.entries()).filter( ([provider, client]) => client.capabilities.supportsSessionListing && @@ -899,6 +922,7 @@ export class AgentManager { await client.listImportableSessions!({ limit: options?.limit, cwd: options?.cwd, + workspace, }) ).map((session) => Object.assign(session, { provider })); } catch (error) { @@ -1114,16 +1138,23 @@ export class AgentManager { config, resolvedAgentId, options?.env, + options.workspaceId, ); this.requireEnabledProvider(storedConfig.provider); - const client = await this.requireAvailableClient({ - provider: storedConfig.provider, - }); + const client = this.requireClient(storedConfig.provider); const launchContext = await this.buildLaunchContext( resolvedAgentId, client, storedConfig.cwd, options?.env, + options.workspaceId, + ); + await this.requireProviderAvailableForLaunch( + client, + storedConfig.provider, + storedConfig.cwd, + options.workspaceId, + launchContext, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const createOptions = this.buildCreateSessionOptions(options); @@ -1194,16 +1225,25 @@ export class AgentManager { const { storedConfig, launchConfig } = await this.prepareSessionConfig( mergedConfig, resolvedAgentId, + undefined, + options?.workspaceId, ); const client = this.requireClient(handle.provider); - const available = await client.isAvailable(); - if (!available) { - throw new Error( - `Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`, - ); - } - const launchContext = await this.buildLaunchContext(resolvedAgentId, client, storedConfig.cwd); + const launchContext = await this.buildLaunchContext( + resolvedAgentId, + client, + storedConfig.cwd, + undefined, + options?.workspaceId, + ); + await this.requireProviderAvailableForLaunch( + client, + storedConfig.provider, + storedConfig.cwd, + options?.workspaceId, + launchContext, + ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const session = await client.resumeSession( handle, @@ -1239,7 +1279,7 @@ export class AgentManager { const resolvedAgentId = validateAgentId(this.idFactory(), "importProviderSession"); this.requireEnabledProvider(input.provider); - const client = await this.requireAvailableClient({ provider: input.provider }); + const client = this.requireClient(input.provider); if (!client.importSession) { throw new Error(`Provider '${input.provider}' does not support importing sessions`); } @@ -1250,8 +1290,23 @@ export class AgentManager { cwd: input.cwd, }, resolvedAgentId, + undefined, + input.workspaceId, + ); + const launchContext = await this.buildLaunchContext( + resolvedAgentId, + client, + storedConfig.cwd, + undefined, + input.workspaceId, + ); + await this.requireProviderAvailableForLaunch( + client, + input.provider, + storedConfig.cwd, + input.workspaceId, + launchContext, ); - const launchContext = await this.buildLaunchContext(resolvedAgentId, client, storedConfig.cwd); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const imported = await client.importSession( { @@ -1264,6 +1319,7 @@ export class AgentManager { try { const importedConfig = await this.normalizeConfig( stripInternalPaseoMcpServer(imported.config), + { validateHostCwd: false }, ); const timelineRows = buildImportedTimelineRows(imported.timeline); const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows); @@ -1331,8 +1387,19 @@ export class AgentManager { ...overrides, provider, } as AgentSessionConfig; - const { storedConfig, launchConfig } = await this.prepareSessionConfig(refreshConfig, agentId); - const launchContext = await this.buildLaunchContext(agentId, client, storedConfig.cwd); + const { storedConfig, launchConfig } = await this.prepareSessionConfig( + refreshConfig, + agentId, + undefined, + existing.workspaceId, + ); + const launchContext = await this.buildLaunchContext( + agentId, + client, + storedConfig.cwd, + undefined, + existing.workspaceId, + ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const session = handle @@ -1597,7 +1664,7 @@ export class AgentManager { await registry.upsert(archivedRecord); - await this.archiveNativeSessionBestEffort(record.provider, record.persistence); + await this.archiveNativeSessionBestEffort(record); if (this.agents.has(record.id)) { this.notifyAgentState(record.id); @@ -1884,7 +1951,7 @@ export class AgentManager { const nextRecord = buildArchivedAgentRecord(record, { archivedAt }); await registry.upsert(nextRecord); - await this.archiveNativeSessionBestEffort(record.provider, record.persistence); + await this.archiveNativeSessionBestEffort(record); if (this.agents.has(agentId)) { this.notifyAgentState(agentId); @@ -1911,7 +1978,7 @@ export class AgentManager { return false; } - await this.unarchiveNativeSession(record.provider, record.persistence); + await this.unarchiveNativeSession(record); await registry.upsert({ ...record, @@ -4449,7 +4516,7 @@ export class AgentManager { const normalized: AgentSessionConfig = { ...config }; // Always resolve cwd to absolute path for consistent history file lookup - if (normalized.cwd) { + if (normalized.cwd && options.validateHostCwd !== false) { normalized.cwd = resolve(normalized.cwd); try { const cwdStats = await stat(normalized.cwd); @@ -4541,8 +4608,12 @@ export class AgentManager { config: AgentSessionConfig, agentId: string, env?: Record, + workspaceId?: string, ): Promise { - const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { env }); + const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { + env, + validateHostCwd: !workspaceId, + }); const launchConfig = this.applyDaemonAppendSystemPrompt( withRuntimePaseoMcpServer({ config: storedConfig, @@ -4572,6 +4643,7 @@ export class AgentManager { client: AgentClient, cwd: string, env?: Record, + workspaceId?: string, ): Promise { const context: AgentLaunchContext = { agentId, @@ -4581,6 +4653,16 @@ export class AgentManager { PASEO_AGENT_CWD: cwd, }, }; + if (workspaceId) { + if (!this.resolveProviderWorkspace) { + throw new Error(`workspace runtime capability is unavailable: ${workspaceId}`); + } + const workspace = await this.resolveProviderWorkspace(workspaceId, cwd); + if (workspace === undefined) { + throw new Error(`workspace runtime capability is unavailable: ${workspaceId}`); + } + if (workspace) context.workspace = workspace; + } if ( this.paseoToolsEnabled && client.capabilities.supportsNativePaseoTools && @@ -4598,16 +4680,34 @@ export class AgentManager { return launchContext.paseoTools ? stripInternalPaseoMcpServer(launchConfig) : launchConfig; } - private async requireAvailableClient(options: { provider: AgentProvider }): Promise { - const client = this.clients.get(options.provider); - if (!client) { - const configuredProviders = this.getConfiguredProviderIds(); - throw new Error( - `Unknown provider '${options.provider}'. Configured providers: ${formatProviderList( - configuredProviders, - )}.`, - ); + private async requireProviderAvailableForLaunch( + client: AgentClient, + provider: AgentProvider, + cwd: string, + workspaceId: string | undefined, + launchContext: AgentLaunchContext, + ): Promise { + if (!launchContext.workspace) { + await this.requireAvailableClient({ provider }); + return; + } + if (!workspaceId) { + throw new Error(`Workspace-scoped provider '${provider}' is missing a workspace ID.`); } + const available = await client.isAvailable({ + scope: "workspace", + cwd, + workspaceId, + workspace: launchContext.workspace, + force: false, + }); + if (!available) { + throw new Error(`Provider '${provider}' is not available in the selected workspace runtime.`); + } + } + + private async requireAvailableClient(options: { provider: AgentProvider }): Promise { + const client = this.requireClient(options.provider); let unavailableReason: string | null = null; try { @@ -4642,20 +4742,30 @@ export class AgentManager { private requireClient(provider: AgentProvider): AgentClient { const client = this.clients.get(provider); if (!client) { - throw new Error(`No client registered for provider '${provider}'`); + const configuredProviders = this.getConfiguredProviderIds(); + throw new Error( + `Unknown provider '${provider}'. Configured providers: ${formatProviderList( + configuredProviders, + )}.`, + ); } return client; } - async archiveNativeSessionBestEffort( - provider: AgentProvider, - persistence: AgentPersistenceHandle | null | undefined, - ): Promise { + async archiveNativeSessionBestEffort(record: StoredAgentRecord): Promise { + const { provider, persistence } = record; if (!persistence) return; const client = this.clients.get(provider); if (!client?.archiveNativeSession) return; try { - await client.archiveNativeSession(persistence); + const launchContext = await this.buildLaunchContext( + record.id, + client, + record.cwd, + undefined, + record.workspaceId, + ); + await client.archiveNativeSession(persistence, launchContext); } catch (error) { this.logger.warn( { error, provider, sessionId: persistence.sessionId }, @@ -4664,14 +4774,19 @@ export class AgentManager { } } - private async unarchiveNativeSession( - provider: AgentProvider, - persistence: AgentPersistenceHandle | null | undefined, - ): Promise { + private async unarchiveNativeSession(record: StoredAgentRecord): Promise { + const { provider, persistence } = record; if (!persistence) return; const client = this.clients.get(provider); if (!client?.unarchiveNativeSession) return; - await client.unarchiveNativeSession(persistence); + const launchContext = await this.buildLaunchContext( + record.id, + client, + record.cwd, + undefined, + record.workspaceId, + ); + await client.unarchiveNativeSession(persistence, launchContext); } private requireAgent(id: string): LiveManagedAgent { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 686c1b24aa..91f2eb52f9 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -6,8 +6,10 @@ import type { } from "@getpaseo/protocol/agent-types"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; import type { PaseoToolCatalog } from "./tools/types.js"; +import type { ProviderWorkspace } from "./providers/workspace/index.js"; export type { AgentProviderNotice, AgentTaskItem }; +export type { ProviderWorkspace } from "./providers/workspace/index.js"; export type AgentProvider = string; @@ -522,6 +524,7 @@ export interface AgentSlashCommand { export interface ListImportableSessionsOptions { limit?: number; + workspace?: ProviderWorkspace; /** * Optional cwd hint. Providers that can cheaply pre-filter importable * sessions by working directory should do so before doing expensive work. @@ -598,6 +601,7 @@ export interface AgentLaunchContext { * AgentSessionConfig; providers may adapt it to their native tool surface. */ paseoTools?: PaseoToolCatalog; + workspace?: ProviderWorkspace; } export interface AgentCreateSessionOptions { @@ -672,6 +676,8 @@ export type FetchCatalogOptions = | { scope: "workspace"; cwd: string; + workspaceId?: string; + workspace?: ProviderWorkspace; force: boolean; }; @@ -737,18 +743,24 @@ export interface AgentClient { * Check if this provider is available (CLI binary is installed). * Returns true if available, false otherwise. */ - isAvailable(signal?: AbortSignal): Promise; + isAvailable(options?: FetchCatalogOptions, signal?: AbortSignal): Promise; getDiagnostic?(): Promise<{ diagnostic: string }>; /** * Archive a durable native session (best-effort). Runtime release belongs to AgentSession.close(). * Called when Paseo archives an agent so the provider's own UI reflects the same state. */ - archiveNativeSession?(handle: AgentPersistenceHandle): Promise; + archiveNativeSession?( + handle: AgentPersistenceHandle, + launchContext?: AgentLaunchContext, + ): Promise; /** * Unarchive a durable native session in the provider. * Called before Paseo clears its archived flag so provider resume can succeed. */ - unarchiveNativeSession?(handle: AgentPersistenceHandle): Promise; + unarchiveNativeSession?( + handle: AgentPersistenceHandle, + launchContext?: AgentLaunchContext, + ): Promise; /** * Release any provider-owned resources held by this client (background * processes, sockets, cached subprocesses, etc.). Called when the daemon diff --git a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts index 53a2177272..2279078e5f 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -4,6 +4,7 @@ import type pino from "pino"; import type { ForgeService } from "../../services/forge-service.js"; import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-service.js"; +import type { PersistedWorkspaceRecord } from "../workspace-registry.js"; import type { CreatePaseoWorktreeWorkflowFn, CreatePaseoWorktreeWorkflowResult, @@ -28,7 +29,12 @@ interface CreateAgentLifecycleDispatchDependencies { archiveAgentForClose: (agentId: string) => Promise; findWorkspaceIdForCwd: (cwd: string) => Promise; listActiveWorkspaces: () => Promise; - archiveWorkspaceRecord: (workspaceId: string) => Promise; + listWorkspaceRecords: () => Promise; + archiveWorkspaceRecord: ( + workspaceId: string, + options?: { releaseBacking?: boolean }, + ) => Promise; + destroyWorkspace: (workspaceId: string) => Promise; emit: (message: SessionOutboundMessage) => void; emitAgentRemove: (agentId: string) => Promise; emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; @@ -198,6 +204,14 @@ export class CreateAgentLifecycleDispatch { throw new Error("Auto-created worktree is not a Paseo-owned worktree"); } + if (options.agentId === null) { + await this.dependencies.destroyWorkspace(createdWorktree.workspace.workspaceId); + await this.dependencies.emitWorkspaceUpdatesForWorkspaceIds([ + createdWorktree.workspace.workspaceId, + ]); + return; + } + await archiveByScope( { paseoHome: this.dependencies.paseoHome, @@ -208,6 +222,7 @@ export class CreateAgentLifecycleDispatch { agentStorage: this.dependencies.agentStorage, findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd, listActiveWorkspaces: this.dependencies.listActiveWorkspaces, + listWorkspaceRecords: this.dependencies.listWorkspaceRecords, archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord, emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds, markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving, @@ -218,6 +233,7 @@ export class CreateAgentLifecycleDispatch { { scope: { kind: "workspace", workspaceId: createdWorktree.workspace.workspaceId }, requestId: randomUUID(), + releaseBacking: true, }, ); diff --git a/packages/server/src/server/agent/create-agent/create.test.ts b/packages/server/src/server/agent/create-agent/create.test.ts index e8547d27ed..96bb4f5965 100644 --- a/packages/server/src/server/agent/create-agent/create.test.ts +++ b/packages/server/src/server/agent/create-agent/create.test.ts @@ -18,6 +18,7 @@ function createRealAgentManager(storage: AgentStorage): AgentManager { return new AgentManager({ clients: createTestAgentClients(), registry: storage, + resolveProviderWorkspace: async () => null, logger, }); } diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index b31a858f34..17dc689119 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -253,6 +253,7 @@ async function resolveSessionCreateAgent( // introduced by this validation). const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({ cwd: builtSessionConfig.cwd, + workspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId, provider: builtSessionConfig.provider, requestedMode: builtSessionConfig.modeId, featureValues: builtSessionConfig.featureValues, @@ -339,6 +340,7 @@ async function resolveMcpCreateAgent( input, provider, resolvedCwd, + workspaceId: intent.workspaceId, parentAgent, }); @@ -387,11 +389,13 @@ async function resolveMcpProviderCreateConfig(params: { input: CreateAgentFromMcpInput; provider: string; resolvedCwd: string; + workspaceId: string; parentAgent: ManagedAgent | null; }): Promise<{ modeId?: string; featureValues?: Record }> { const passthroughConfig = params.input.config; return params.dependencies.providerSnapshotManager.resolveCreateConfig({ cwd: params.resolvedCwd, + workspaceId: params.workspaceId, provider: params.provider, requestedMode: params.input.mode ?? passthroughConfig?.modeId, featureValues: params.input.features ?? passthroughConfig?.featureValues, diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index ed22fefbab..ad9a8c94ae 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -132,6 +132,7 @@ export async function listImportableProviderSessions( limit: limit + importedSessions.count, providerFilter, cwd: request.cwd, + workspaceId: request.workspaceId, }); let filteredAlreadyImportedCount = 0; const candidates: ManagedImportableProviderSession[] = []; diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 349eb15c53..ac4447c486 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -58,6 +58,8 @@ import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-to import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js"; import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js"; import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js"; +import { resolveHostProviderWorkspace } from "../test-utils/provider-workspace-stub.js"; +import { createWorkspaceRuntimeService } from "../workspace-runtime/index.js"; const REPO_CWD = resolvePath("/tmp/repo"); const TARGET_CWD = resolvePath("/tmp/target"); @@ -178,7 +180,22 @@ async function waitForUnexpectedWorkspaceNamingSideEffects(): Promise { await new Promise((resolve) => setTimeout(resolve, 25)); } +const mcpWorktreeRuntimes = new Map< + string, + { + service: ReturnType; + workspaces: Map; + } +>(); + async function removeTempDir(path: string): Promise { + for (const [paseoHome, runtime] of mcpWorktreeRuntimes) { + if (!paseoHome.startsWith(`${path}/`)) continue; + for (const workspace of Array.from(runtime.workspaces.values())) { + if (workspace.runtime) await runtime.service.destroy(workspace.workspaceId); + } + mcpWorktreeRuntimes.delete(paseoHome); + } await rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } @@ -739,6 +756,27 @@ function createPaseoWorktreeForMcpTest(options: { workspaceGitService, logger: createTestLogger(), }); + const workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome: options.paseoHome, + worktreesRoot: join(options.paseoHome, "worktrees"), + resolveRuntimeId: async (workspaceId) => + workspaces.get(workspaceId)?.runtime?.runtimeId ?? null, + persistRuntimeId: async (workspaceId, runtimeId, placement) => { + const workspace = workspaces.get(workspaceId); + if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`); + workspaces.set(workspaceId, { + ...workspace, + cwd: placement.cwd, + hostVisiblePath: placement.hostVisiblePath ?? null, + runtime: { runtimeId }, + }); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + workspaces.delete(workspaceId); + }, + }); + mcpWorktreeRuntimes.set(options.paseoHome, { service: workspaceRuntime, workspaces }); const workspaceAutoName = new WorkspaceAutoName({ agentManager: buildAgentManagerSpies() as unknown as AgentManager, workspaceRegistry, @@ -774,6 +812,8 @@ function createPaseoWorktreeForMcpTest(options: { : {}), workspaceGitService, workspaceProvisioning, + workspaceRuntime, + workspaceRegistry, }), warmWorkspaceGitData: async () => {}, autoNameWorkspaceBranchForFirstAgent: (autoNameInput) => @@ -791,6 +831,7 @@ function createPaseoWorktreeForMcpTest(options: { getDaemonTcpPort: null, getDaemonTcpHost: null, onScriptsChanged: null, + bindWorkspaceRuntime: (workspaceId) => workspaceRuntime.bind(workspaceId), }, input, serviceOptions, @@ -2754,7 +2795,9 @@ describe("create_agent MCP tool", () => { force: true, reason: "archive-worktree", }); - expect(archiveWorkspaceRecord).toHaveBeenCalledWith("ws-archive-tool-worktree"); + expect(archiveWorkspaceRecord).toHaveBeenCalledWith("ws-archive-tool-worktree", { + releaseBacking: true, + }); expect(markWorkspaceArchiving).toHaveBeenCalledWith( ["ws-archive-tool-worktree"], expect.any(String), @@ -3303,6 +3346,7 @@ describe("create_agent MCP tool", () => { clients: createTestAgentClients(), registry: storage, logger, + resolveProviderWorkspace: resolveHostProviderWorkspace, }); try { diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 620d1c4960..9c587e4b87 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -9,6 +9,7 @@ import type { AgentModelDefinition, AgentProvider, FetchCatalogOptions, + ProviderWorkspace, ProviderRefreshContext, ResolveAgentCreateConfigInput, } from "./agent-sdk-types.js"; @@ -30,6 +31,16 @@ const TEST_CAPABILITIES = { } as const; const TEST_REFRESH_TIMEOUT_MS = 120_000; +function deferred() { + let complete!: (value: T) => void; + let fail!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + complete = resolvePromise; + fail = rejectPromise; + }); + return { promise, resolve: complete, reject: fail }; +} + // Builds an AgentClient that can be injected via the public extraClients option. // extraClients is the only injection surface the manager exposes for tests. function createExtraClient( @@ -75,6 +86,13 @@ function waitUntilAborted(signal?: AbortSignal): Promise { }); } +function waitUntilAvailabilityAborted( + _options?: FetchCatalogOptions, + signal?: AbortSignal, +): Promise { + return waitUntilAborted(signal); +} + function waitForDelay(delayMs: number): Promise { return new Promise((finish) => setTimeout(finish, delayMs)); } @@ -416,7 +434,9 @@ describe("ProviderSnapshotManager public surface", () => { test("refreshTimeoutMs option overrides the default and yields a timeout error", async () => { // never-resolving isAvailable forces the timeout path - const isAvailable = vi.fn(waitUntilAborted); + const isAvailable = vi.fn((_options?: FetchCatalogOptions, signal?: AbortSignal) => + waitUntilAborted(signal), + ); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), refreshTimeoutMs: 1, @@ -453,7 +473,9 @@ describe("ProviderSnapshotManager public surface", () => { pi: { enabled: false }, }, extraClients: { - codex: createExtraClient("codex", { isAvailable: vi.fn(waitUntilAborted) }), + codex: createExtraClient("codex", { + isAvailable: vi.fn(waitUntilAvailabilityAborted), + }), }, }); manager.setRefreshTimeoutMs(1); @@ -481,7 +503,7 @@ describe("ProviderSnapshotManager public surface", () => { pi: { enabled: false }, }, extraClients: { - codex: createExtraClient("codex", { isAvailable: waitUntilAborted }), + codex: createExtraClient("codex", { isAvailable: waitUntilAvailabilityAborted }), }, }); @@ -655,7 +677,7 @@ describe("ProviderSnapshotManager public surface", () => { test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is honored when no option is given", async () => { vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1"); - const isAvailable = vi.fn(waitUntilAborted); + const isAvailable = vi.fn(waitUntilAvailabilityAborted); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), providerOverrides: { @@ -682,7 +704,7 @@ describe("ProviderSnapshotManager public surface", () => { test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is ignored when option is provided", async () => { vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1"); - const isAvailable = vi.fn(waitUntilAborted); + const isAvailable = vi.fn(waitUntilAvailabilityAborted); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), refreshTimeoutMs: 5, @@ -947,20 +969,21 @@ describe("ProviderSnapshotManager public surface", () => { }); test("getProviderDiagnostic starts provider diagnostics before waiting for snapshot refresh", async () => { - vi.useFakeTimers(); - let diagnosticStarted = false; + const diagnosticStarted = deferred(); + const catalogStarted = deferred(); + const catalog = deferred<{ models: AgentModelDefinition[]; modes: AgentMode[] }>(); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), refreshTimeoutMs: TEST_REFRESH_TIMEOUT_MS, extraClients: { codex: createExtraClient("codex", { isAvailable: async () => true, - fetchCatalog: async (_options, context) => { - await context?.runActivity("model/list", () => waitUntilAborted(context.signal)); - return { models: [], modes: [] }; + fetchCatalog: async () => { + catalogStarted.resolve(); + return catalog.promise; }, getDiagnostic: async () => { - diagnosticStarted = true; + diagnosticStarted.resolve(); return { diagnostic: "codex diagnostics available" }; }, }), @@ -968,77 +991,59 @@ describe("ProviderSnapshotManager public surface", () => { }); try { const diagnosticRequest = manager.getProviderDiagnostic("codex"); - expect(diagnosticStarted).toBe(true); - - const diagnosticOrBlocked = Promise.race([ - diagnosticRequest.then(() => ({ type: "diagnostic" as const })), - new Promise<{ type: "blocked" }>((finish) => { - setTimeout(() => finish({ type: "blocked" }), 1); - }), - ]); - await vi.advanceTimersByTimeAsync(1); - await expect(diagnosticOrBlocked).resolves.toEqual({ type: "blocked" }); - - await vi.advanceTimersByTimeAsync(TEST_REFRESH_TIMEOUT_MS - 1); + await Promise.all([diagnosticStarted.promise, catalogStarted.promise]); + catalog.resolve({ + models: [{ provider: "codex", id: "gpt-5.4-mini", label: "GPT 5.4 Mini" }], + modes: [], + }); const result = await diagnosticRequest; expect(result.diagnostic).toContain("codex diagnostics available"); - expect(result.diagnostic).toContain( - `Status: Error: Timed out refreshing Codex after ${TEST_REFRESH_TIMEOUT_MS}ms`, - ); + expect(result.diagnostic).toContain("Status: Ready"); } finally { manager.destroy(); - vi.useRealTimers(); } }); test("getProviderDiagnostic starts snapshot refresh even when provider diagnostics hang", async () => { - vi.useFakeTimers(); - let diagnosticStarted = false; - let snapshotStarted = false; + const diagnosticStarted = deferred(); + const snapshotStarted = deferred(); + const diagnostic = deferred<{ diagnostic: string }>(); + const catalog = deferred<{ models: AgentModelDefinition[]; modes: AgentMode[] }>(); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), refreshTimeoutMs: TEST_REFRESH_TIMEOUT_MS, extraClients: { codex: createExtraClient("codex", { isAvailable: async () => true, - fetchCatalog: async (_options, context) => { - snapshotStarted = true; - await context?.runActivity("model/list", () => waitUntilAborted(context.signal)); - return { models: [], modes: [] }; + fetchCatalog: async () => { + snapshotStarted.resolve(); + return catalog.promise; }, getDiagnostic: async () => { - diagnosticStarted = true; - return new Promise(() => {}); + diagnosticStarted.resolve(); + return diagnostic.promise; }, }), }, }); try { const diagnosticRequest = manager.getProviderDiagnostic("codex"); - await vi.advanceTimersByTimeAsync(0); - - expect(diagnosticStarted).toBe(true); - expect(snapshotStarted).toBe(true); - - await vi.advanceTimersByTimeAsync(TEST_REFRESH_TIMEOUT_MS); + await Promise.all([diagnosticStarted.promise, snapshotStarted.promise]); + catalog.resolve({ models: [], modes: [] }); + diagnostic.resolve({ diagnostic: "barrier diagnostic" }); const result = await diagnosticRequest; - expect(result.diagnostic).toContain( - `Error: Timed out collecting Codex diagnostic after ${TEST_REFRESH_TIMEOUT_MS}ms`, - ); - expect(result.diagnostic).toContain( - `Status: Error: Timed out refreshing Codex after ${TEST_REFRESH_TIMEOUT_MS}ms`, - ); + expect(result.diagnostic).toContain("barrier diagnostic"); + expect(result.diagnostic).toContain("Status: Ready"); } finally { manager.destroy(); - vi.useRealTimers(); } }); test("getProviderDiagnostic reports provider diagnostic timeout while preserving snapshot details", async () => { - vi.useFakeTimers(); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), - refreshTimeoutMs: TEST_REFRESH_TIMEOUT_MS, + refreshTimeoutMs: 1_000, + diagnosticTimeoutMs: 1, extraClients: { codex: createExtraClient("codex", { isAvailable: async () => true, @@ -1051,43 +1056,32 @@ describe("ProviderSnapshotManager public surface", () => { }, }); try { - const diagnosticRequest = manager.getProviderDiagnostic("codex"); - await vi.advanceTimersByTimeAsync(TEST_REFRESH_TIMEOUT_MS); - - const result = await diagnosticRequest; - expect(result.diagnostic).toContain( - `Error: Timed out collecting Codex diagnostic after ${TEST_REFRESH_TIMEOUT_MS}ms`, - ); + const result = await manager.getProviderDiagnostic("codex"); + expect(result.diagnostic).toContain("Error: Timed out collecting Codex diagnostic after 1ms"); expect(result.diagnostic).toContain("Models: 1"); expect(result.diagnostic).toContain("Status: Ready"); } finally { manager.destroy(); - vi.useRealTimers(); } }); test("getProviderDiagnostic reports a stuck catalog refresh inside the diagnostic", async () => { await withEnv("PASEO_ENABLE_MOCK_SLOW", "true", async () => { - vi.useFakeTimers(); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), isDev: true, - refreshTimeoutMs: TEST_REFRESH_TIMEOUT_MS, + refreshTimeoutMs: 1, }); try { - const diagnosticRequest = manager.getProviderDiagnostic("mock-slow"); - await vi.advanceTimersByTimeAsync(TEST_REFRESH_TIMEOUT_MS); - - const result = await diagnosticRequest; + const result = await manager.getProviderDiagnostic("mock-slow"); expect(result.provider).toBe("mock-slow"); expect(result.diagnostic).toContain("Mock slow provider"); expect(result.diagnostic).toContain("Models: —"); expect(result.diagnostic).toContain( - `Status: Error: Timed out refreshing Mock Slow Provider after ${TEST_REFRESH_TIMEOUT_MS}ms`, + "Status: Error: Timed out refreshing Mock Slow Provider after 1ms", ); } finally { manager.destroy(); - vi.useRealTimers(); } }); }); @@ -1608,6 +1602,75 @@ describe("ProviderSnapshotManager lifecycle", () => { }); describe("ProviderSnapshotManager cwd routing", () => { + test("keys selected workspace probes by workspace id and runtime reconstruction", async () => { + const workspaces = new Map([ + ["workspace-a", createProviderWorkspace()], + ["workspace-b", createProviderWorkspace()], + ]); + const probes: string[] = []; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + resolveProviderWorkspace: async (workspaceId) => workspaces.get(workspaceId) ?? null, + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: async (options) => { + if (options.scope !== "workspace" || !options.workspaceId || !options.workspace) { + throw new Error("Expected a bound provider workspace"); + } + probes.push(options.workspaceId); + return { + models: [{ provider: "codex", id: options.workspaceId, label: options.workspaceId }], + modes: [], + }; + }, + }), + }, + }); + + try { + await manager.warmUpSnapshotForCwd({ + cwd: "/same/cwd", + workspaceId: "workspace-a", + providers: ["codex"], + }); + await manager.warmUpSnapshotForCwd({ + cwd: "/same/cwd", + workspaceId: "workspace-b", + providers: ["codex"], + }); + await manager.warmUpSnapshotForCwd({ + cwd: "/same/cwd", + workspaceId: "workspace-a", + providers: ["codex"], + }); + expect(probes).toEqual(["workspace-a", "workspace-b"]); + expect( + manager.getSnapshot("/same/cwd", "workspace-a").find((entry) => entry.provider === "codex") + ?.models?.[0]?.id, + ).toBe("workspace-a"); + expect( + manager.getSnapshot("/same/cwd", "workspace-b").find((entry) => entry.provider === "codex") + ?.models?.[0]?.id, + ).toBe("workspace-b"); + + workspaces.set("workspace-a", createProviderWorkspace()); + await manager.warmUpSnapshotForCwd({ + cwd: "/same/cwd", + workspaceId: "workspace-a", + providers: ["codex"], + }); + await manager.warmUpSnapshotForCwd({ + cwd: "/same/cwd", + workspaceId: "workspace-b", + providers: ["codex"], + }); + expect(probes).toEqual(["workspace-a", "workspace-b", "workspace-a"]); + } finally { + manager.destroy(); + } + }); + test("settings refresh passes the semantic global scope to providers", async () => { const fetchCatalog = vi.fn(async () => ({ models: [] as AgentModelDefinition[], @@ -1717,3 +1780,45 @@ describe("ProviderSnapshotManager cwd routing", () => { } }); }); + +function createProviderWorkspace(): ProviderWorkspace { + return { + cwd: ".", + async resolveExecutable() { + throw new Error("not used"); + }, + async launch() { + throw new Error("not used"); + }, + launchDeferred() { + throw new Error("not used"); + }, + async runProbe() { + throw new Error("not used"); + }, + async readWorkspaceText() { + throw new Error("not used"); + }, + async writeWorkspaceText() { + throw new Error("not used"); + }, + async listState() { + return { entries: [] }; + }, + async readStateText() { + throw new Error("not used"); + }, + async findStateFile() { + return null; + }, + async materializeStateFile() { + throw new Error("not used"); + }, + async removeStateFile() { + throw new Error("not used"); + }, + allowsHostService() { + return false; + }, + }; +} diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 99728efc5f..c5ab52ee38 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -15,6 +15,7 @@ import { type AgentProvider, type FetchCatalogOptions, type ProviderSnapshotEntry, + type ProviderWorkspace, } from "./agent-sdk-types.js"; import { raceProviderRefreshAbort, @@ -91,7 +92,11 @@ function omitProviderOverrides( return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined; } -type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void; +type ProviderSnapshotChangeListener = ( + entries: ProviderSnapshotEntry[], + cwd: string, + workspaceId?: string, +) => void; export interface ProviderSnapshotManagerOptions { logger: Logger; @@ -103,24 +108,33 @@ export interface ProviderSnapshotManagerOptions { extraClients?: Partial>; refreshTimeoutMs?: number; diagnosticTimeoutMs?: number; + resolveProviderWorkspace?: (workspaceId: string) => Promise; } interface ProviderSnapshotRefreshOptions { cwd: string; + workspaceId?: string; providers?: AgentProvider[]; } interface ProviderSnapshotWarmUpOptions { cwd?: string | null; + workspaceId?: string; providers?: AgentProvider[]; } interface ProviderSnapshotReadOptions { cwd?: string | null; + workspaceId?: string; providers?: AgentProvider[]; wait?: boolean; } +interface ProviderSnapshotRequestOptions { + cwd?: string | null; + workspaceId?: string; +} + interface ApplyMutableProviderConfigOptions { removeProviders?: readonly string[]; replace?: boolean; @@ -134,12 +148,14 @@ export interface StagedMutableProviderConfig { interface ProviderSnapshotProviderOptions { cwd?: string | null; + workspaceId?: string; provider: AgentProvider; wait?: boolean; } export interface ResolveProviderCreateConfigOptions { cwd?: string | null; + workspaceId?: string; provider: AgentProvider; requestedMode: string | undefined; featureValues: Record | undefined; @@ -186,6 +202,15 @@ interface ProviderLoad { promise: Promise; } +type ProviderCatalogScope = + | { scope: "global" } + | { + scope: "workspace"; + cwd: string; + workspaceId?: string; + workspace?: ProviderWorkspace; + }; + interface MutableProviderState { baseProviderOverrides: Record | undefined; runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; @@ -196,8 +221,6 @@ interface MutableProviderState { providerLoads: Map>; } -type ProviderCatalogScope = { scope: "global" } | { scope: "workspace"; cwd: string }; - interface ProviderSnapshotTarget { snapshotCwd: string; catalogScope: ProviderCatalogScope; @@ -215,6 +238,13 @@ export class ProviderSnapshotManager { private readonly managedProcesses?: ManagedProcessRegistry; private readonly isDev: boolean; private readonly extraClients: Partial>; + private readonly resolveProviderWorkspace?: ProviderSnapshotManagerOptions["resolveProviderWorkspace"]; + private readonly workspaceSnapshotKeys = new Map(); + private readonly workspaceTargets = new Map(); + private readonly snapshotDisplayCwds = new Map(); + private readonly snapshotWorkspaceIds = new Map(); + private readonly runtimeTokens = new WeakMap(); + private nextRuntimeToken = 0; private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; private providerOverrides: Record | undefined; private baseProviderOverrides: Record | undefined; @@ -228,6 +258,7 @@ export class ProviderSnapshotManager { this.managedProcesses = options.managedProcesses; this.isDev = options.isDev === true; this.extraClients = options.extraClients ?? {}; + this.resolveProviderWorkspace = options.resolveProviderWorkspace; this.runtimeSettings = options.runtimeSettings; this.providerOverrides = options.providerOverrides; this.baseProviderOverrides = options.providerOverrides; @@ -241,17 +272,41 @@ export class ProviderSnapshotManager { for (const client of Object.values(this.providerClients)) this.ownedClients.add(client); } - getSnapshot(cwd?: string): ProviderSnapshotEntry[] { - const target = resolveProviderSnapshotTarget(cwd); + getSnapshot(cwd?: string, workspaceId?: string): ProviderSnapshotEntry[] { + const target = workspaceId + ? (this.workspaceTargets.get(workspaceId) ?? + createWorkspaceSnapshotTarget(resolveSnapshotCwd(cwd))) + : resolveProviderSnapshotTarget(cwd); return this.getSnapshotForTarget(target); } + async readSnapshot(options: ProviderSnapshotRequestOptions): Promise { + try { + await this.warmUpSnapshotForCwd(options); + return this.getSnapshot(options.cwd ?? undefined, options.workspaceId); + } catch (error) { + if (!options.workspaceId) throw error; + const message = error instanceof Error ? error.message : String(error); + const unavailable: ProviderSnapshotEntry[] = []; + for (const entry of entriesToArray( + this.getOrCreateSnapshot(`workspace-unavailable:${options.workspaceId}`), + )) { + unavailable.push( + entry.enabled + ? { ...entry, status: "error", error: message } + : { ...entry, status: "unavailable" }, + ); + } + return unavailable; + } + } + async refreshSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise { const snapshotCwd = resolveSnapshotCwd(options.cwd); - const target = createWorkspaceSnapshotTarget(snapshotCwd); + const target = await this.createWorkspaceTarget(snapshotCwd, options.workspaceId); const providers = this.resolveRefreshProviders(options.providers); - this.resetSnapshotToLoading(snapshotCwd, providers, { preserveExisting: false }); - this.emitChange(snapshotCwd); + this.resetSnapshotToLoading(target.snapshotCwd, providers, { preserveExisting: false }); + this.emitChange(target.snapshotCwd); await this.refreshProviders(target, providers ?? this.getProviderIds()); } @@ -270,7 +325,9 @@ export class ProviderSnapshotManager { } async warmUpSnapshotForCwd(options: ProviderSnapshotWarmUpOptions): Promise { - const target = resolveProviderSnapshotTarget(options.cwd); + const target = options.workspaceId + ? await this.createWorkspaceTarget(resolveSnapshotCwd(options.cwd), options.workspaceId) + : resolveProviderSnapshotTarget(options.cwd); const snapshotCwd = target.snapshotCwd; const providers = this.resolveRefreshProviders(options.providers); if (options.providers && providers?.length === 0) { @@ -335,10 +392,17 @@ export class ProviderSnapshotManager { } async listProviders(input: ProviderSnapshotReadOptions = {}): Promise { - const target = resolveProviderSnapshotTarget(input.cwd); if (input.wait) { - await this.warmUpSnapshotForCwd({ cwd: input.cwd, providers: input.providers }); + await this.warmUpSnapshotForCwd({ + cwd: input.cwd, + workspaceId: input.workspaceId, + providers: input.providers, + }); } + const target = input.workspaceId + ? (this.workspaceTargets.get(input.workspaceId) ?? + createWorkspaceSnapshotTarget(resolveSnapshotCwd(input.cwd))) + : resolveProviderSnapshotTarget(input.cwd); const providerFilter = input.providers ? new Set(input.providers) : null; const entries = this.getSnapshotForTarget(target); return providerFilter ? entries.filter((entry) => providerFilter.has(entry.provider)) : entries; @@ -427,6 +491,7 @@ export class ProviderSnapshotManager { ): Promise { const entry = await this.getReadyProvider({ cwd: input.cwd, + workspaceId: input.workspaceId, provider: input.provider, wait: true, }); @@ -936,14 +1001,17 @@ export class ProviderSnapshotManager { label: definition.label, timeoutMs: this.refreshTimeoutMs, operation: async (context) => { + const catalogOptions = createFetchCatalogOptions(catalogScope, force); const available = await context.runActivity("availability", () => - raceProviderRefreshAbort(context.signal, client.isAvailable(context.signal)), + raceProviderRefreshAbort( + context.signal, + client.isAvailable(catalogOptions, context.signal), + ), ); if (!available) { return null; } - const catalogOptions = createFetchCatalogOptions(catalogScope, force); return await definition.fetchCatalog(catalogOptions, client, context); }, }); @@ -963,6 +1031,10 @@ export class ProviderSnapshotManager { fetchedAt: new Date().toISOString(), }); } catch (error) { + if (isProviderCommandUnavailableError(error)) { + setEntry({ ...base, status: "unavailable", enabled: true }); + return; + } const emitted = setEntry({ ...base, status: "error", @@ -978,6 +1050,42 @@ export class ProviderSnapshotManager { } } + private async createWorkspaceTarget( + cwd: string, + workspaceId?: string, + ): Promise { + if (!workspaceId) return createWorkspaceSnapshotTarget(cwd); + if (!this.resolveProviderWorkspace) { + throw new Error(`Provider workspace resolver is unavailable: ${workspaceId}`); + } + const workspace = await this.resolveProviderWorkspace(workspaceId); + if (workspace === undefined) { + throw new Error(`Workspace runtime capability is unavailable: ${workspaceId}`); + } + if (workspace === null) return createWorkspaceSnapshotTarget(cwd); + let token = this.runtimeTokens.get(workspace); + if (!token) { + token = ++this.nextRuntimeToken; + this.runtimeTokens.set(workspace, token); + } + const snapshotKey = `workspace:${workspaceId}:${token}`; + const previous = this.workspaceSnapshotKeys.get(workspaceId); + if (previous && previous !== snapshotKey) { + this.snapshots.delete(previous); + this.providerLoads.delete(previous); + this.snapshotWorkspaceIds.delete(previous); + } + this.workspaceSnapshotKeys.set(workspaceId, snapshotKey); + const target = { + snapshotCwd: snapshotKey, + catalogScope: { scope: "workspace", cwd, workspaceId, workspace }, + } satisfies ProviderSnapshotTarget; + this.workspaceTargets.set(workspaceId, target); + this.snapshotDisplayCwds.set(snapshotKey, cwd); + this.snapshotWorkspaceIds.set(snapshotKey, workspaceId); + return target; + } + private getProviderLoad(cwdKey: string, provider: AgentProvider): ProviderLoad | undefined { return this.providerLoads.get(cwdKey)?.get(provider); } @@ -1007,7 +1115,12 @@ export class ProviderSnapshotManager { if (!snapshot) { return; } - this.events.emit("change", entriesToArray(snapshot), cwdKey); + this.events.emit( + "change", + entriesToArray(snapshot), + this.snapshotDisplayCwds.get(cwdKey) ?? cwdKey, + this.snapshotWorkspaceIds.get(cwdKey), + ); } private getOrCreateSnapshot(cwdKey: string): Map { @@ -1070,6 +1183,14 @@ export class ProviderSnapshotManager { } } +function isProviderCommandUnavailableError(error: unknown): boolean { + const message = toErrorMessage(error); + return ( + /^Provider command '.+' was not found in the workspace$/u.test(message) || + / command '.+' not found$/u.test(message) + ); +} + export function resolveSnapshotCwd(cwd?: string | null): string { const trimmed = cwd?.trim(); if (!trimmed) { @@ -1116,7 +1237,13 @@ function createFetchCatalogOptions( ): FetchCatalogOptions { return scope.scope === "global" ? { scope: "global", force } - : { scope: "workspace", cwd: scope.cwd, force }; + : { + scope: "workspace", + cwd: scope.cwd, + workspaceId: scope.workspaceId, + workspace: scope.workspace, + force, + }; } export function isGlobalProviderSnapshotKey(cwd: string): boolean { diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 1a43ed5538..68d1ce7615 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -92,6 +92,7 @@ import { type ListImportableSessionsOptions, type McpServerConfig, type ProviderCatalog, + type ProviderWorkspace, type ResolveAgentCreateConfigInput, type ResolveAgentCreateConfigResult, type ToolCallDetail, @@ -125,6 +126,12 @@ import { truncateForDiagnostic, } from "./diagnostic-utils.js"; import { withTimeout } from "../../../utils/promise-timeout.js"; +import { + providerWorkspaceEnvironment, + providerWorkspaceFromCatalogOptions, + resolveWorkspaceCommand, + spawnWorkspaceProviderProcess, +} from "./workspace/index.js"; const ACP_AUTO_ACCEPT_FEATURE_ID = "auto_accept"; @@ -465,6 +472,7 @@ interface ACPAgentSessionOptions { handle?: AgentPersistenceHandle; agentId?: string; launchEnv?: Record; + workspace?: ProviderWorkspace; waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number; terminateProcess?: ProcessTerminator; @@ -522,7 +530,7 @@ interface TerminalExit { interface TerminalEntry { id: string; - child: ChildProcess; + terminate(): Promise; output: string; truncated: boolean; outputByteLimit: number | null; @@ -858,7 +866,11 @@ export class ACPAgentClient implements AgentClient { ): Promise { this.assertProvider(config); const session = new ACPAgentSession( - { ...config, provider: this.provider }, + { + ...config, + provider: this.provider, + cwd: launchContext?.workspace?.cwd ?? config.cwd, + }, { provider: this.provider, logger: this.logger, @@ -879,6 +891,7 @@ export class ACPAgentClient implements AgentClient { capabilities: this.capabilities, agentId: launchContext?.agentId, launchEnv: launchContext?.env, + workspace: launchContext?.workspace, extensionCommandsParser: this.extensionCommandsParser, waitForInitialCommands: this.waitForInitialCommands, initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs, @@ -909,6 +922,7 @@ export class ACPAgentClient implements AgentClient { provider: this.provider, cwd, }; + if (launchContext?.workspace) mergedConfig.cwd = launchContext.workspace.cwd; const session = new ACPAgentSession(mergedConfig, { provider: this.provider, logger: this.logger, @@ -930,6 +944,7 @@ export class ACPAgentClient implements AgentClient { handle, agentId: launchContext?.agentId, launchEnv: launchContext?.env, + workspace: launchContext?.workspace, extensionCommandsParser: this.extensionCommandsParser, waitForInitialCommands: this.waitForInitialCommands, initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs, @@ -942,7 +957,8 @@ export class ACPAgentClient implements AgentClient { options: FetchCatalogOptions, context?: ProviderRefreshContext, ): Promise { - const cwd = options.scope === "global" ? homedir() : options.cwd; + const workspace = providerWorkspaceFromCatalogOptions(options); + const cwd = workspace?.cwd ?? (options.scope === "global" ? homedir() : options.cwd); let probe: UninitializedACPProcess | null = null; let closePromise: Promise | null = null; const closeProbe = (): Promise => { @@ -958,6 +974,7 @@ export class ACPAgentClient implements AgentClient { raceProviderRefreshAbort( context?.signal, this.spawnProcess(PROBE_ENV, { + workspace, onSpawned: (spawned) => { probe = spawned; if (context?.signal.aborted) void closeProbe().catch(() => undefined); @@ -1046,7 +1063,7 @@ export class ACPAgentClient implements AgentClient { async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { - const probe = await this.spawnProcess(PROBE_ENV); + const probe = await this.spawnProcess(PROBE_ENV, { workspace: options?.workspace }); try { if (!probe.initialize.agentCapabilities?.sessionCapabilities?.list) { return []; @@ -1061,7 +1078,7 @@ export class ACPAgentClient implements AgentClient { // Filter by working directory at the source. Without this the agent // returns globally-recent sessions, which the `limit` below can // truncate before the current directory's sessions are reached. - ...(options?.cwd ? { cwd: options.cwd } : {}), + ...(options?.cwd ? { cwd: options.workspace?.cwd ?? options.cwd } : {}), }), ); for (const session of page.sessions) { @@ -1094,9 +1111,10 @@ export class ACPAgentClient implements AgentClient { }); } - async isAvailable(): Promise { + async isAvailable(options?: FetchCatalogOptions): Promise { try { - await this.resolveLaunchCommand(); + const workspace = options ? providerWorkspaceFromCatalogOptions(options) : undefined; + await this.resolveLaunchCommand(workspace); return true; } catch { return false; @@ -1108,9 +1126,10 @@ export class ACPAgentClient implements AgentClient { options?: { initializeTimeoutMs?: number; onSpawned?: (probe: UninitializedACPProcess) => void; + workspace?: ProviderWorkspace; }, ): Promise { - const transport = await this.spawnTransport(launchEnv); + const transport = await this.spawnTransport(launchEnv, options?.workspace); const probe: UninitializedACPProcess = { child: transport.child, connection: transport.connection, @@ -1131,8 +1150,20 @@ export class ACPAgentClient implements AgentClient { } } - protected async spawnTransport(launchEnv?: Record): Promise { - const { command, args } = await this.resolveLaunchCommand(); + protected async spawnTransport( + launchEnv?: Record, + workspace?: ProviderWorkspace, + ): Promise { + const { command, args } = await this.resolveLaunchCommand(workspace); + if (workspace) { + const child = await spawnWorkspaceProviderProcess({ + workspace, + argv: [command, ...args], + env: providerWorkspaceEnvironment([this.runtimeSettings?.env, launchEnv]), + purpose: { kind: "provider-probe", provider: this.provider }, + }); + return this.createTransport(child, workspace); + } const child = spawnProcess(command, args, { cwd: process.cwd(), ...createProviderEnvSpec({ @@ -1142,7 +1173,13 @@ export class ACPAgentClient implements AgentClient { stdio: ["pipe", "pipe", "pipe"], }); assertChildWithPipes(child); + return this.createTransport(child); + } + private createTransport( + child: ChildProcessWithoutNullStreams, + workspace?: ProviderWorkspace, + ): ACPProcessTransport { const stderrChunks: string[] = []; child.stderr.on("data", (chunk: Buffer | string) => { stderrChunks.push(chunk.toString()); @@ -1165,7 +1202,7 @@ export class ACPAgentClient implements AgentClient { Readable.toWeb(child.stdout), { logger: this.logger, provider: this.provider }, ); - const connection = new ClientSideConnection(() => this.buildProbeClient(), stream); + const connection = new ClientSideConnection(() => this.buildProbeClient(workspace), stream); return { child, @@ -1211,17 +1248,22 @@ export class ACPAgentClient implements AgentClient { } } - protected buildProbeClient(): ACPClient { + protected buildProbeClient(workspace?: ProviderWorkspace): ACPClient { return { async requestPermission(): Promise { return { outcome: { outcome: "cancelled" } }; }, async sessionUpdate(): Promise {}, async readTextFile(params: ReadTextFileRequest) { + if (workspace) return { content: await readWorkspaceTextFile(workspace, params.path) }; const content = await fs.readFile(params.path, "utf8"); return { content }; }, async writeTextFile(params: WriteTextFileRequest) { + if (workspace) { + await writeWorkspaceTextFile(workspace, params.path, params.content); + return {}; + } await fs.mkdir(path.dirname(params.path), { recursive: true }); await fs.writeFile(params.path, params.content, "utf8"); return {}; @@ -1358,17 +1400,19 @@ export class ACPAgentClient implements AgentClient { } } - protected async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> { + protected async resolveLaunchCommand( + workspace?: ProviderWorkspace, + ): Promise<{ command: string; args: string[] }> { const prefix = await resolveProviderLaunch({ commandConfig: this.runtimeSettings?.command, defaultBinary: this.defaultCommand[0], }); - const availability = await checkProviderLaunchAvailable(prefix); - if (!availability.available) { - throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`); - } + const resolvedCommand = workspace + ? await resolveWorkspaceCommand(workspace, prefix.command) + : (await checkProviderLaunchAvailable(prefix)).resolvedPath; + if (!resolvedCommand) throw new Error(`${this.provider} command '${prefix.command}' not found`); return { - command: prefix.command, + command: resolvedCommand, args: [...prefix.args, ...this.defaultCommand.slice(1)], }; } @@ -1426,6 +1470,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { ) => Promise; private readonly agentId?: string; private readonly launchEnv?: Record; + private readonly workspace?: ProviderWorkspace; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly pendingPermissions = new Map(); private pendingUserMessage: PendingUserMessage | null = null; @@ -1485,6 +1530,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.availableModes = options.defaultModes; this.agentId = options.agentId; this.launchEnv = options.launchEnv; + this.workspace = options.workspace; this.initialHandle = options.handle; this.config = { ...config, provider: options.provider }; this.currentMode = config.modeId ?? null; @@ -2212,10 +2258,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { } const terminalTerminations = Array.from(this.terminalEntries.values(), (terminal) => - this.terminateProcess(terminal.child, { - gracefulTimeoutMs: 2_000, - forceTimeoutMs: 2_000, - }), + terminal.terminate(), ); await Promise.all(terminalTerminations); this.terminalEntries.clear(); @@ -2366,7 +2409,9 @@ export class ACPAgentSession implements AgentSession, ACPClient { } async readTextFile(params: ReadTextFileRequest): Promise<{ content: string }> { - const raw = await fs.readFile(params.path, "utf8"); + const raw = this.workspace + ? await readWorkspaceTextFile(this.workspace, params.path) + : await fs.readFile(params.path, "utf8"); if (!params.line && !params.limit) { return { content: raw }; } @@ -2377,6 +2422,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { } async writeTextFile(params: WriteTextFileRequest): Promise> { + if (this.workspace) { + await writeWorkspaceTextFile(this.workspace, params.path, params.content); + return {}; + } await fs.mkdir(path.dirname(params.path), { recursive: true }); await fs.writeFile(params.path, params.content, "utf8"); return {}; @@ -2390,14 +2439,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { const terminalCommand = resolveTerminalCommand(params.command, params.args); const commandEnvOverlays = terminalCommand.shell === false ? [env, createStringCommandShellEnvOverlay()] : [env]; - const child = spawnProcess(terminalCommand.command, terminalCommand.args, { - cwd: params.cwd ?? this.config.cwd, - ...createProviderEnvSpec({ - runtimeSettings: this.runtimeSettings, - overlays: commandEnvOverlays, - }), - shell: terminalCommand.shell, - stdio: ["ignore", "pipe", "pipe"], + const cwd = params.cwd ?? this.config.cwd; + const envSpec = createProviderEnvSpec({ + runtimeSettings: this.runtimeSettings, + overlays: commandEnvOverlays, }); let resolveExit!: (exit: TerminalExit) => void; @@ -2408,9 +2453,8 @@ export class ACPAgentSession implements AgentSession, ACPClient { }); waitForExit.catch(() => undefined); - const entry: TerminalEntry = { + const entryBase = { id: terminalId, - child, output: "", truncated: false, outputByteLimit: params.outputByteLimit ?? null, @@ -2420,6 +2464,54 @@ export class ACPAgentSession implements AgentSession, ACPClient { rejectExit, }; + if (this.workspace) { + const relativeCwd = path.relative(this.config.cwd, cwd); + if (relativeCwd.startsWith("..") || path.isAbsolute(relativeCwd)) { + throw new Error(`ACP terminal cwd escapes workspace: ${cwd}`); + } + const child = await this.workspace.launch({ + cwd: relativeCwd || undefined, + argv: [terminalCommand.command, ...terminalCommand.args], + environment: [this.runtimeSettings?.env, ...commandEnvOverlays], + purpose: { kind: "terminal", terminalId }, + }); + const entry: TerminalEntry = { + ...entryBase, + terminate: () => terminateChildProcess(child, 2_000, this.terminateProcess), + }; + child.stdout.on("data", (chunk: Buffer | string) => + appendTerminalOutput(entry, chunk.toString()), + ); + child.stderr.on("data", (chunk: Buffer | string) => + appendTerminalOutput(entry, chunk.toString()), + ); + child.once("error", rejectExit); + child.once("exit", (code, signal) => { + const exit = { exitCode: code, signal }; + entry.exit = exit; + resolveExit(exit); + }); + child.stdin.end(); + this.terminalEntries.set(terminalId, entry); + return { terminalId }; + } + + const child = spawnProcess(terminalCommand.command, terminalCommand.args, { + cwd, + ...envSpec, + shell: terminalCommand.shell, + stdio: ["ignore", "pipe", "pipe"], + }); + const entry: TerminalEntry = { + ...entryBase, + terminate: async () => { + await this.terminateProcess(child, { + gracefulTimeoutMs: 2_000, + forceTimeoutMs: 2_000, + }); + }, + }; + child.stdout!.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString()), ); @@ -2458,7 +2550,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { async releaseTerminal(params: { sessionId: string; terminalId: string }): Promise { const entry = this.getTerminalEntry(params.terminalId); if (!entry.exit) { - await this.terminateProcess(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 }); + await entry.terminate(); } this.terminalEntries.delete(params.terminalId); } @@ -2466,7 +2558,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { async killTerminal(params: KillTerminalRequest): Promise> { const entry = this.getTerminalEntry(params.terminalId); if (!entry.exit) { - await this.terminateProcess(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 }); + await entry.terminate(); } return {}; } @@ -2476,13 +2568,24 @@ export class ACPAgentSession implements AgentSession, ACPClient { commandConfig: this.runtimeSettings?.command, defaultBinary: this.defaultCommand[0], }); - const availability = await checkProviderLaunchAvailable(prefix); - if (!availability.available) { - throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`); - } - - const command = prefix.command; + const command = this.workspace + ? await resolveWorkspaceCommand(this.workspace, prefix.command) + : (await checkProviderLaunchAvailable(prefix)).resolvedPath; + if (!command) throw new Error(`${this.provider} command '${prefix.command}' not found`); const args = [...prefix.args, ...this.defaultCommand.slice(1)]; + if (this.workspace) { + const child = await spawnWorkspaceProviderProcess({ + workspace: this.workspace, + argv: [command, ...args], + env: providerWorkspaceEnvironment([this.runtimeSettings?.env, this.launchEnv]), + purpose: { + kind: "agent", + agentId: this.agentId ?? "unidentified-agent", + provider: this.provider, + }, + }); + return await this.initializeSpawnedChild(child); + } const child = spawnProcess(command, args, { cwd: this.config.cwd, ...createProviderEnvSpec({ @@ -2492,7 +2595,12 @@ export class ACPAgentSession implements AgentSession, ACPClient { stdio: ["pipe", "pipe", "pipe"], }); assertChildWithPipes(child); + return await this.initializeSpawnedChild(child); + } + private async initializeSpawnedChild( + child: ChildProcessWithoutNullStreams, + ): Promise { const stderrChunks: string[] = []; child.stderr.on("data", (chunk: Buffer | string) => { stderrChunks.push(chunk.toString()); @@ -3683,3 +3791,30 @@ async function terminateChildProcess( child.stderr.destroy(); } } + +async function readWorkspaceTextFile( + workspace: ProviderWorkspace, + requestedPath: string, +): Promise { + return workspace.readWorkspaceText(toWorkspaceRelativePath(requestedPath)); +} + +async function writeWorkspaceTextFile( + workspace: ProviderWorkspace, + requestedPath: string, + content: string, +): Promise { + await workspace.writeWorkspaceText(toWorkspaceRelativePath(requestedPath), content); +} + +function toWorkspaceRelativePath(requestedPath: string): string { + const normalized = path.normalize(requestedPath); + if ( + path.isAbsolute(normalized) || + normalized === ".." || + normalized.startsWith(`..${path.sep}`) + ) { + throw new Error(`ACP file path escapes the selected workspace: ${requestedPath}`); + } + return normalized; +} diff --git a/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts b/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts new file mode 100644 index 0000000000..0f253dd584 --- /dev/null +++ b/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts @@ -0,0 +1,207 @@ +import { execFileSync } from "node:child_process"; +import { chmod, copyFile, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, test } from "vitest"; + +import { createWorkspaceRuntimeService } from "../../workspace-runtime/index.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { ACPAgentSession } from "./acp-agent.js"; +import { GenericACPAgentClient } from "./generic-acp-agent.js"; +import { bindProviderWorkspace } from "./workspace/index.js"; + +const fixtureAgent = new URL( + "../../../../../../runtimes/fixture/test/fixtures/workspace-runtime-acp-agent.mjs", + import.meta.url, +); +const fixtureRuntime = fileURLToPath( + new URL("../../../../../../runtimes/fixture/src/index.mjs", import.meta.url), +); + +const posixDescribe = describe.runIf(process.platform !== "win32"); + +posixDescribe("ACP workspace terminal execution", () => { + test("uses the selected workspace runtime for ACP-created terminal commands", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-acp-runtime-")); + const cwd = path.join(root, "workspace"); + await mkdir(cwd); + const runtimeIds = new Map(); + const runtime = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => { + runtimeIds.set(workspaceId, runtimeId); + }, + ...lifecycleRecords(runtimeIds), + }); + await runtime.create({ + workspaceId: "acp-workspace", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: cwd } }, + placement: { kind: "existing" }, + }); + const session = new ACPAgentSession( + { provider: "acp-test", cwd }, + { + provider: "acp-test", + logger: createTestLogger(), + defaultCommand: ["unused"], + defaultModes: [], + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: false, + supportsImages: false, + supportsFileAttachments: false, + supportsAudioAttachments: false, + supportsModes: false, + supportsModels: false, + supportsThinking: false, + supportsMcp: false, + supportsSlashCommands: false, + }, + workspace: bindProviderWorkspace({ + runtime: await runtime.bind("acp-workspace"), + cwd: ".", + policy: { + environment: { type: "inherit-sanitized-host", hostEnvironment: process.env }, + sharedHostProviders: new Set(["opencode"]), + }, + }), + }, + ); + + try { + const terminal = await session.createTerminal({ + sessionId: "session", + command: process.execPath, + args: ["-e", "process.stdout.write(`${process.cwd()}|λ`);process.exit(12)"], + cwd, + }); + await expect( + session.waitForTerminalExit({ sessionId: "session", terminalId: terminal.terminalId }), + ).resolves.toEqual({ exitCode: 12, signal: null }); + await expect( + session.terminalOutput({ sessionId: "session", terminalId: terminal.terminalId }), + ).resolves.toMatchObject({ output: `${await realpath(cwd)}|λ` }); + } finally { + await session.close(); + await runtime.destroy("acp-workspace"); + await rm(root, { recursive: true, force: true }); + } + }); + + test.each(["local", "worktree", "fixture"] as const)( + "discovers and launches an ACP provider through the selected %s workspace", + async (selectedRuntimeId) => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-acp-provider-runtime-")); + const cwd = path.join(root, "workspace"); + await mkdir(cwd); + await copyFile(fixtureAgent, path.join(cwd, "fixture-agent.mjs")); + await chmod(path.join(cwd, "fixture-agent.mjs"), 0o755); + await writeFile(path.join(cwd, "committed.txt"), "before\n"); + execFileSync("git", ["init", "-b", "main"], { cwd }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { cwd }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd }); + execFileSync("git", ["add", "."], { cwd }); + execFileSync("git", ["commit", "-m", "fixture"], { cwd }); + const runtimeIds = new Map(); + const fixtureStateDirectory = path.join(root, "fixture-state"); + await mkdir(fixtureStateDirectory); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, persistedRuntimeId) => + runtimeIds.set(workspaceId, persistedRuntimeId), + externalRuntimes: + selectedRuntimeId === "fixture" + ? { + fixture: { + type: "command", + command: [process.execPath, fixtureRuntime], + options: { stateDirectory: fixtureStateDirectory }, + }, + } + : undefined, + ...lifecycleRecords(runtimeIds), + }); + await service.create({ + workspaceId: "provider-workspace", + runtimeId: selectedRuntimeId, + project: { id: "project", source: { kind: "host-directory", path: cwd } }, + placement: + selectedRuntimeId !== "worktree" + ? { kind: "existing" } + : { kind: "branch", branchName: "provider-worktree", baseRef: "main" }, + }); + const workspace = bindProviderWorkspace({ + runtime: await service.bind("provider-workspace"), + cwd: ".", + policy: { + environment: { + type: "inherit-sanitized-host", + hostEnvironment: { + ...process.env, + PASEO_DAEMON_ONLY_PROVIDER_ENV: "daemon-visible", + }, + }, + sharedHostProviders: new Set(["opencode"]), + }, + }); + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: ["./fixture-agent.mjs"], + }); + + try { + await expect( + client.isAvailable({ + scope: "workspace", + cwd, + workspaceId: "provider-workspace", + workspace, + force: false, + }), + ).resolves.toBe(true); + await expect( + client.fetchCatalog({ + scope: "workspace", + cwd, + workspaceId: "provider-workspace", + workspace, + force: false, + timeoutMs: 2_000, + }), + ).resolves.toMatchObject({ models: [{ id: "fixture-model" }] }); + const session = await client.createSession( + { provider: "acp", cwd }, + { agentId: "fixture-agent", workspace }, + ); + await expect(session.run("runtime edit")).resolves.toMatchObject({ + finalText: "fixture completed: runtime edit", + }); + await expect(workspace.readWorkspaceText("stdio-agent-output.txt")).resolves.toBe( + "runtime edit\n", + ); + await expect(workspace.readWorkspaceText("stdio-agent-env.txt")).resolves.toBe( + "daemon-visible\n", + ); + await session.close(); + } finally { + await service.destroy("provider-workspace"); + await rm(root, { recursive: true, force: true }); + } + }, + 15_000, + ); +}); + +function lifecycleRecords(runtimeIds: Map) { + return { + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId: string) => { + runtimeIds.delete(workspaceId); + }, + }; +} diff --git a/packages/server/src/server/agent/providers/claude/agent.spawn.test.ts b/packages/server/src/server/agent/providers/claude/agent.spawn.test.ts index 0ccc74b055..45cce2b613 100644 --- a/packages/server/src/server/agent/providers/claude/agent.spawn.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.spawn.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from "node:events"; -import type { ChildProcess } from "node:child_process"; +import type { ChildProcess, ChildProcessWithoutNullStreams } from "node:child_process"; +import { PassThrough } from "node:stream"; import type { Options, Query, @@ -11,6 +12,8 @@ import { createTestLogger } from "../../../../test-utils/test-logger.js"; import * as spawnUtils from "../../../../utils/spawn.js"; import { ClaudeAgentClient } from "./agent.js"; import type { ClaudeQueryInput } from "./query.js"; +import type { ProviderWorkspace } from "../../agent-sdk-types.js"; +import type { ProviderWorkspaceLaunchInput } from "../workspace/index.js"; function createQueryMock(events: unknown[]): Query { let index = 0; @@ -36,6 +39,8 @@ function createQueryMock(events: unknown[]): Query { function createChildProcessStub(): ChildProcess { const child = new EventEmitter() as ChildProcess; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); child.stderr = new EventEmitter() as ChildProcess["stderr"]; return child; } @@ -102,4 +107,48 @@ describe("Claude spawn override", () => { const spawnOptions = claudeSpawnCall?.[2]; expect(spawnOptions?.shell).toBe(false); }); + + test("forwards the Claude SDK abort signal into selected workspace placement", async () => { + let capturedOptions: Options | undefined; + let resolveLaunch!: (launch: ProviderWorkspaceLaunchInput) => void; + const launched = new Promise((resolve) => { + resolveLaunch = resolve; + }); + const child = createChildProcessStub() as ChildProcessWithoutNullStreams; + const workspace = { + cwd: ".", + async resolveExecutable(command: string) { + return command; + }, + launchDeferred(input: ProviderWorkspaceLaunchInput | Promise) { + void Promise.resolve(input).then(resolveLaunch); + return child; + }, + } as ProviderWorkspace; + const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => { + capturedOptions = options; + return createQueryMock([]); + }); + const client = new ClaudeAgentClient({ logger: createTestLogger(), queryFactory }); + const session = await client.createSession( + { provider: "claude", cwd: process.cwd() }, + { agentId: "selected-claude", workspace }, + ); + const controller = new AbortController(); + + try { + await session.listCommands?.(); + capturedOptions?.spawnClaudeCodeProcess?.({ + command: "node", + args: ["claude.js"], + cwd: process.cwd(), + env: {}, + signal: controller.signal, + } satisfies ClaudeSpawnOptions); + await expect(launched).resolves.toMatchObject({ signal: controller.signal }); + } finally { + child.emit("exit", 0, null); + await session.close(); + } + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 72652bee4a..96730bb751 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -17,13 +17,61 @@ import { } from "./agent.js"; import { claudeProjectDirSync } from "./project-dir.js"; import { streamSession } from "../test-utils/session-stream-adapter.js"; -import type { AgentSession, AgentTimelineItem, AgentStreamEvent } from "../../agent-sdk-types.js"; +import type { + AgentSession, + AgentTimelineItem, + AgentStreamEvent, + ProviderWorkspace, +} from "../../agent-sdk-types.js"; interface TestClaudeSession { translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; close(): Promise; } +function createStateWorkspace(overrides: Partial = {}): ProviderWorkspace { + return { + cwd: ".", + async resolveExecutable(command) { + return command; + }, + async launch() { + throw new Error("not used"); + }, + launchDeferred() { + throw new Error("not used"); + }, + async runProbe() { + throw new Error("not used"); + }, + async readWorkspaceText() { + throw new Error("not used"); + }, + async writeWorkspaceText() { + throw new Error("not used"); + }, + async listState(statePath) { + return { path: statePath, entries: [] }; + }, + async readStateText() { + throw new Error("not used"); + }, + async findStateFile() { + return null; + }, + async materializeStateFile() { + throw new Error("not used"); + }, + async removeStateFile() { + throw new Error("not used"); + }, + allowsHostService() { + return false; + }, + ...overrides, + }; +} + afterEach(() => { vi.restoreAllMocks(); }); @@ -1862,6 +1910,143 @@ describe("ClaudeAgentSession context window usage", () => { } }); + test("deletes an ephemeral selected-runtime transcript through provider state access", async () => { + const sessionId = "selected-ephemeral"; + const transcript = `.claude/projects/project/${sessionId}.jsonl`; + const removeStateFile = vi.fn(async () => undefined); + const workspace = createStateWorkspace({ + findStateFile: async () => transcript, + removeStateFile, + }); + const queryFactory = createQueryFactoryForTurns([ + [ + { type: "system", subtype: "init", session_id: sessionId, permissionMode: "default" }, + { + type: "result", + subtype: "success", + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + num_turns: 1, + result: "done", + stop_reason: null, + total_cost_usd: 0, + usage: {}, + permission_denials: [], + uuid: `${sessionId}-result`, + session_id: sessionId, + }, + ], + ]); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => { + throw new Error("selected runtime must resolve the executable"); + }, + }); + const session = await client.createSession( + { provider: "claude", cwd: "/host/path-that-must-not-be-read" }, + { agentId: "selected-agent", workspace }, + { persistSession: false }, + ); + + await session.run("turn"); + await session.close(); + + expect(removeStateFile).toHaveBeenCalledWith(transcript); + }); + + test("loads selected-runtime resume and sidechain files through provider state access", async () => { + const sessionId = "selected-resume"; + const transcript = `.claude/projects/project/${sessionId}.jsonl`; + const sidechainDirectory = `.claude/projects/project/${sessionId}/subagents`; + const sidechain = `${sidechainDirectory}/agent-child.jsonl`; + const readStateText = vi.fn(async (statePath: string) => { + if (statePath === transcript) return ""; + if (statePath === sidechain) return ""; + throw new Error(`Unexpected state read: ${statePath}`); + }); + const workspace = createStateWorkspace({ + findStateFile: async () => transcript, + readStateText, + async listState(statePath) { + if (statePath.endsWith("/workflows")) return { path: statePath, entries: [] }; + if (statePath === sidechainDirectory) { + return { + path: statePath, + entries: [ + { + name: "agent-child.jsonl", + path: sidechain, + kind: "file", + size: 0, + modifiedAt: "2026-08-11T00:00:00.000Z", + }, + ], + }; + } + throw new Error(`Unexpected state listing: ${statePath}`); + }, + }); + const client = new ClaudeAgentClient({ + logger, + resolveBinary: async () => { + throw new Error("selected runtime must resolve the executable"); + }, + }); + const session = await client.resumeSession( + { provider: "claude", sessionId, metadata: { cwd: "/host/path-that-must-not-be-read" } }, + undefined, + { agentId: "selected-agent", workspace }, + ); + + await Array.fromAsync(session.streamHistory()); + + expect(readStateText).toHaveBeenCalledWith(transcript); + expect(readStateText).toHaveBeenCalledWith(sidechain); + await session.close(); + }); + + test("treats a proven missing selected-runtime transcript as empty history", async () => { + const workspace = createStateWorkspace({ + findStateFile: async () => null, + }); + const client = new ClaudeAgentClient({ logger }); + const session = await client.resumeSession( + { provider: "claude", sessionId: "missing-transcript", metadata: { cwd: "/host/cwd" } }, + undefined, + { agentId: "selected-agent", workspace }, + ); + + await expect(Array.fromAsync(session.streamHistory())).resolves.toEqual([]); + await session.close(); + }); + + test.each([ + ["runtime listing", { findStateFile: async () => Promise.reject(new Error("runtime paused")) }], + [ + "transcript read", + { + findStateFile: async () => ".claude/projects/project/selected-failure.jsonl", + readStateText: async () => Promise.reject(new Error("permission denied")), + }, + ], + ])("fails selected-runtime resume history closed on %s failure", async (_name, overrides) => { + const workspace = createStateWorkspace(overrides); + const client = new ClaudeAgentClient({ logger }); + const session = await client.resumeSession( + { provider: "claude", sessionId: "selected-failure", metadata: { cwd: "/host/cwd" } }, + undefined, + { agentId: "selected-agent", workspace }, + ); + + await expect(Array.fromAsync(session.streamHistory())).rejects.toThrow( + /runtime paused|permission denied/, + ); + await session.close(); + }); + test("preserves the persisted session jsonl on close when persistSession is undefined", async () => { const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-persist-")); const previousConfigDir = process.env.CLAUDE_CONFIG_DIR; diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 22873f4075..8a92c8b6c2 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -32,6 +32,7 @@ import { } from "./task-notification-tool-call.js"; import { findClaudeModel, + getClaudeModels, getClaudeModelsWithSettings, normalizeClaudeRuntimeModelId, resolveConfiguredClaudeModel, @@ -121,6 +122,7 @@ import { type ListImportableSessionsOptions, type McpServerConfig, type ProviderCatalog, + type ProviderWorkspace, type ProviderRefreshContext, type ResolveAgentDefaultModeInput, } from "../../agent-sdk-types.js"; @@ -138,6 +140,12 @@ import { withTimeout } from "../../../../utils/promise-timeout.js"; import { terminateWithTreeKill } from "../../../../utils/tree-kill.js"; import { execCommand } from "../../../../utils/spawn.js"; import { composeSystemPromptParts } from "../../system-prompt.js"; +import { + isProviderStateNotFoundError, + providerWorkspaceEnvironment, + providerWorkspaceFromCatalogOptions, + runWorkspaceProviderCommand, +} from "../workspace/index.js"; const fsPromises = promises; const CLAUDE_SETTING_SOURCES: NonNullable = [ @@ -388,6 +396,16 @@ export interface ClaudeContentChunk { [key: string]: unknown; } +function renderClaudeToolResultImage( + image: ProviderImageOutput, + selectedWorkspace: boolean, +): AgentTimelineItem | null { + return renderProviderImageOutputAsAssistantMarkdown( + image, + selectedWorkspace ? {} : { materialize: materializeProviderImage }, + ); +} + interface ClaudeAgentClientOptions { defaults?: { agents?: Record }; logger: Logger; @@ -408,6 +426,7 @@ interface ClaudeAgentSessionOptions { logger: Logger; queryFactory?: ClaudeQueryFactory; resolveBinary: () => Promise; + workspace?: ProviderWorkspace; } type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max"; @@ -1511,7 +1530,8 @@ export class ClaudeAgentClient implements AgentClient { persistSession: options?.persistSession, logger: this.logger, queryFactory: this.queryFactory, - resolveBinary: this.resolveBinary, + resolveBinary: launchContext?.workspace ? async () => "claude" : this.resolveBinary, + workspace: launchContext?.workspace, }); } @@ -1539,28 +1559,35 @@ export class ClaudeAgentClient implements AgentClient { launchEnv: launchContext?.env, logger: this.logger, queryFactory: this.queryFactory, - resolveBinary: this.resolveBinary, + resolveBinary: launchContext?.workspace ? async () => "claude" : this.resolveBinary, + workspace: launchContext?.workspace, }); } async fetchCatalog( - _options: FetchCatalogOptions, + options: FetchCatalogOptions, context?: ProviderRefreshContext, ): Promise { - // Claude exposes a global catalog here; cwd/force are intentionally irrelevant. + const workspace = providerWorkspaceFromCatalogOptions(options); let claudeCodeVersion: string | undefined; try { claudeCodeVersion = await runProviderRefreshActivity(context, "version", () => - this.resolveVersion(context?.signal), + workspace + ? resolveWorkspaceClaudeCodeVersion(workspace, this.runtimeSettings) + : this.resolveVersion(context?.signal), ); } catch (error) { this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog"); } - const models = await runProviderRefreshActivity(context, "settings", () => - getClaudeModelsWithSettings(this.logger, this.configDir, claudeCodeVersion), - ); + const models = workspace + ? getClaudeModels(claudeCodeVersion) + : await runProviderRefreshActivity(context, "settings", () => + getClaudeModelsWithSettings(this.logger, this.configDir, claudeCodeVersion), + ); const modes = detectIneligibleAutoModeTransport( - createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }), + workspace + ? providerWorkspaceEnvironment([this.runtimeSettings?.env]) + : createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }), ) ? DEFAULT_MODES.filter((mode) => mode.id !== "auto") : DEFAULT_MODES; @@ -1591,6 +1618,7 @@ export class ClaudeAgentClient implements AgentClient { async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { + if (options?.workspace) return []; const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); const sessionsRoot = options?.cwd ? claudeProjectDirSync(options.cwd, { configDir }) @@ -1619,11 +1647,22 @@ export class ClaudeAgentClient implements AgentClient { }); } - async isAvailable(): Promise { + async isAvailable(options?: FetchCatalogOptions): Promise { const launch = await resolveProviderLaunch({ commandConfig: this.runtimeSettings?.command, defaultBinary: "claude", }); + if (options) { + try { + const workspace = providerWorkspaceFromCatalogOptions(options); + if (workspace) { + await workspace.resolveExecutable(launch.command); + return true; + } + } catch { + return false; + } + } const availability = await checkProviderLaunchAvailable(launch); return availability.available; } @@ -1709,6 +1748,26 @@ export async function resolveClaudeCodeVersion( return version.join("."); } +async function resolveWorkspaceClaudeCodeVersion( + workspace: ProviderWorkspace, + runtimeSettings?: ProviderRuntimeSettings, +): Promise { + const launch = await resolveProviderLaunch({ + commandConfig: runtimeSettings?.command, + defaultBinary: "claude", + }); + const executable = await workspace.resolveExecutable(launch.command); + const { stdout, stderr } = await runWorkspaceProviderCommand({ + workspace, + argv: [executable, ...launch.args, "--version"], + env: runtimeSettings?.env, + provider: "claude", + }); + const version = parseClaudeCodeVersion(`${stdout}\n${stderr}`); + if (!version) throw new Error("Unable to parse Claude Code version from --version output"); + return version.join("."); +} + async function resolveClaudeAuth( launch: ResolvedProviderLaunch, availability: { resolvedPath: string | null }, @@ -2018,6 +2077,7 @@ class ClaudeAgentSession implements AgentSession { private readonly logger: Logger; private readonly queryFactory?: ClaudeQueryFactory; private readonly resolveBinary: () => Promise; + private readonly workspace?: ProviderWorkspace; private query: Query | null = null; private childProcess: ChildProcess | null = null; private input: AsyncMessageInput | null = null; @@ -2037,7 +2097,8 @@ class ClaudeAgentSession implements AgentSession { private readonly taskState = new ClaudeTaskState(); private readonly taskProtocolSource = new ClaudeTaskProtocolSource({ getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null, - readWorkflowResult: readClaudeWorkflowResultFile, + readWorkflowResult: (outputFile) => + this.workspace ? undefined : readClaudeWorkflowResultFile(outputFile), }); private readonly sidechainTracker = new ClaudeSidechainTracker({ getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null, @@ -2054,6 +2115,7 @@ class ClaudeAgentSession implements AgentSession { { type: "provider_subagent" } >[] = []; private historyPending = false; + private historyLoadPromise: Promise = Promise.resolve(); private turnState: TurnState = "idle"; private nextTurnOrdinal = 1; private cancelCurrentTurn: (() => void) | null = null; @@ -2085,6 +2147,7 @@ class ClaudeAgentSession implements AgentSession { this.logger = options.logger.child({ agentId: this.agentId }); this.queryFactory = options.queryFactory; this.resolveBinary = options.resolveBinary; + this.workspace = options.workspace; this.contextUsage = new ClaudeContextUsageState( findClaudeModel(this.config.model)?.contextWindowMaxTokens, ); @@ -2096,7 +2159,7 @@ class ClaudeAgentSession implements AgentSession { } this.claudeSessionId = handle.sessionId; this.persistence = handle; - this.loadPersistedHistory(handle.sessionId); + this.historyLoadPromise = this.loadPersistedHistory(handle.sessionId); } else { this.claudeSessionId = null; this.persistence = null; @@ -2274,6 +2337,7 @@ class ClaudeAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { + await this.historyLoadPromise; if ( !this.historyPending || (this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0) @@ -2559,10 +2623,11 @@ class ClaudeAgentSession implements AgentSession { // (see `claude --help`), so the SDK's persistSession=false is silently dropped // in stream-json mode. Sweep the transcript ourselves so ephemeral runs // (metadata generator, branch-name generator) don't show up as resumable. - const historyPath = this.resolveHistoryPath(this.claudeSessionId); + const historyPath = await this.resolveHistoryPath(this.claudeSessionId); if (historyPath) { try { - await promises.rm(historyPath, { force: true }); + if (this.workspace) await this.workspace.removeStateFile(historyPath); + else await promises.rm(historyPath, { force: true }); } catch (error) { this.logger.warn( { err: error, historyPath, claudeSessionId: this.claudeSessionId }, @@ -2805,7 +2870,7 @@ class ClaudeAgentSession implements AgentSession { this.emittedUserMessageIds.clear(); this.rewindTurnAnchors.length = 0; this.taskState.reset(); - this.loadPersistedHistory(sessionId); + this.historyLoadPromise = this.loadPersistedHistory(sessionId); if (oldSessionId && oldSessionId !== sessionId) { this.dispatchEvents([ { @@ -2988,6 +3053,8 @@ class ClaudeAgentSession implements AgentSession { runtimeSettings: this.runtimeSettings, launchEnv: this.launchEnv, queryFactory: this.queryFactory, + workspace: this.workspace, + agentId: this.agentId, onChildProcess: (child) => { this.childProcess = child; child.once("exit", (code, signal) => this.handleRuntimeExit(child, code, signal)); @@ -4568,21 +4635,25 @@ class ClaudeAgentSession implements AgentSession { } } - private loadPersistedHistory(sessionId: string): void { + private async loadPersistedHistory(sessionId: string): Promise { try { this.taskState.reset(); - const historyPath = this.resolveHistoryPath(sessionId); - if (!historyPath || !fs.existsSync(historyPath)) { - return; - } - const content = fs.readFileSync(historyPath, "utf8"); + const historyPath = await this.resolveHistoryPath(sessionId); + if (!historyPath) return; + const content = this.workspace + ? await this.workspace.readStateText(historyPath) + : fs.readFileSync(historyPath, "utf8"); const restoredProviderSubagentIds = this.ingestPersistedSidechains( content, - readClaudeSidechainHistory(historyPath), + this.workspace + ? await readClaudeSidechainHistoryFromWorkspace(historyPath, this.workspace) + : readClaudeSidechainHistory(historyPath), ); this.ingestPersistedHistory(content, restoredProviderSubagentIds); - } catch { - // ignore history load failures + } catch (error) { + if (!this.workspace || isProviderStateNotFoundError(error)) return; + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to load selected Claude history: ${message}`, { cause: error }); } } @@ -4720,7 +4791,10 @@ class ClaudeAgentSession implements AgentSession { } } - private resolveHistoryPath(sessionId: string): string | null { + private async resolveHistoryPath(sessionId: string): Promise { + if (this.workspace) { + return this.workspace.findStateFile(".claude/projects", `${sessionId}.jsonl`); + } const cwd = this.config.cwd; if (!cwd) return null; const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); @@ -4940,9 +5014,7 @@ class ClaudeAgentSession implements AgentSession { } for (const image of images) { - const imageItem = renderProviderImageOutputAsAssistantMarkdown(image, { - materialize: materializeProviderImage, - }); + const imageItem = renderClaudeToolResultImage(image, Boolean(this.workspace)); if (imageItem) { items.push(imageItem); } @@ -5340,6 +5412,7 @@ class ClaudeAgentSession implements AgentSession { } private detectFileKind(filePath: string): string { + if (this.workspace) return "update"; try { return fs.existsSync(filePath) ? "update" : "add"; } catch { @@ -5492,11 +5565,11 @@ interface ClaudeSidechainHistory { const CLAUDE_SUBAGENT_META_FILE = /^agent-(.+)\.meta\.json$/; function readClaudeSidechainHistory(historyPath: string): ClaudeSidechainHistory { - const sessionDirectory = path.join( - path.dirname(historyPath), - path.basename(historyPath, ".jsonl"), + const sessionDirectory = path.posix.join( + path.posix.dirname(historyPath), + path.posix.basename(historyPath, ".jsonl"), ); - const sidechainDirectory = path.join(sessionDirectory, "subagents"); + const sidechainDirectory = path.posix.join(sessionDirectory, "subagents"); const history: ClaudeSidechainHistory = { contents: [], workflowContents: [], @@ -5548,12 +5621,83 @@ function readClaudeSidechainHistory(historyPath: string): ClaudeSidechainHistory return history; } +async function readClaudeSidechainHistoryFromWorkspace( + historyPath: string, + workspace: ProviderWorkspace, +): Promise { + const sessionDirectory = path.posix.join( + path.posix.dirname(historyPath), + path.posix.basename(historyPath, ".jsonl"), + ); + const sidechainDirectory = path.posix.join(sessionDirectory, "subagents"); + const history: ClaudeSidechainHistory = { + contents: [], + workflowContents: [], + workflowSidechainContentsByRunId: new Map(), + metaByAgentId: new Map(), + }; + try { + const workflows = await workspace.listState(path.posix.join(sessionDirectory, "workflows")); + for (const entry of workflows.entries) { + if (entry.kind !== "file" || !entry.name.endsWith(".json")) continue; + try { + history.workflowContents.push(await workspace.readStateText(entry.path)); + } catch (error) { + if (!isProviderStateNotFoundError(error)) throw error; + } + } + } catch (error) { + if (!isProviderStateNotFoundError(error)) throw error; + } + + const directories = [sidechainDirectory]; + while (directories.length > 0) { + const directory = directories.pop()!; + let entries; + try { + entries = (await workspace.listState(directory)).entries; + } catch (error) { + if (!isProviderStateNotFoundError(error)) throw error; + continue; + } + for (const entry of entries) { + if (entry.kind === "directory") { + directories.push(entry.path); + continue; + } + if (entry.name.endsWith(".jsonl")) { + const contents = await workspace.readStateText(entry.path); + recordClaudeSidechainContent(history, sidechainDirectory, entry.path, contents); + continue; + } + const metaMatch = CLAUDE_SUBAGENT_META_FILE.exec(entry.name); + if (!metaMatch?.[1]) continue; + try { + const meta = parseClaudeSubagentMeta(await workspace.readStateText(entry.path)); + if (meta) history.metaByAgentId.set(metaMatch[1], meta); + } catch (error) { + if (!isProviderStateNotFoundError(error)) throw error; + } + } + } + return history; +} + function recordClaudeSidechainContents( history: ClaudeSidechainHistory, sidechainDirectory: string, entryPath: string, ): void { const contents = fs.readFileSync(entryPath, "utf8"); + recordClaudeSidechainContent(history, sidechainDirectory, entryPath, contents); +} + +function recordClaudeSidechainContent( + history: ClaudeSidechainHistory, + sidechainDirectory: string, + entryPath: string, + contents: string, +): void { const relativeParts = path.relative(sidechainDirectory, entryPath).split(path.sep); const workflowRunId = relativeParts[0] === "workflows" && relativeParts.length >= 3 ? relativeParts[1] : undefined; diff --git a/packages/server/src/server/agent/providers/claude/query.ts b/packages/server/src/server/agent/providers/claude/query.ts index ccd511816a..ce8d3de976 100644 --- a/packages/server/src/server/agent/providers/claude/query.ts +++ b/packages/server/src/server/agent/providers/claude/query.ts @@ -8,6 +8,8 @@ import { } from "../../provider-launch-config.js"; import { buildSelfNodeCommand } from "../../../paseo-env.js"; import { spawnProcess } from "../../../../utils/spawn.js"; +import type { ProviderWorkspace } from "../../agent-sdk-types.js"; +import { providerWorkspaceEnvironment, resolveWorkspaceCommand } from "../workspace/index.js"; // Keep the raw SDK query import in this module only. Claude process launch behavior // must stay shared between production and tests so Windows .cmd/.bat handling cannot @@ -23,6 +25,8 @@ export interface ClaudeQueryContext { queryFactory?: ClaudeQueryFactory; /** Called with the spawned child process so the caller can tree-kill it on close. */ onChildProcess?: (child: ChildProcess) => void; + workspace?: ProviderWorkspace; + agentId?: string; } function isChildProcessWithStreams(child: ChildProcess): child is ChildProcessWithoutNullStreams { @@ -58,7 +62,7 @@ function applyRuntimeSettingsToClaudeOptions( options: ClaudeOptions, context: ClaudeQueryContext, ): ClaudeOptions { - const { runtimeSettings, launchEnv, onChildProcess } = context; + const { runtimeSettings, launchEnv, onChildProcess, workspace } = context; return { ...options, spawnClaudeCodeProcess: (spawnOptions) => { @@ -84,6 +88,29 @@ function applyRuntimeSettingsToClaudeOptions( : null; const command = selfNodeCommand?.command ?? resolved.command; const args = selfNodeCommand?.args ?? resolved.args; + if (workspace) { + const workspaceArgs = isDefaultRuntime ? resolved.args.slice(1) : args; + const child = workspace.launchDeferred( + (async () => ({ + argv: [ + await resolveWorkspaceCommand(workspace, isDefaultRuntime ? "claude" : command), + ...workspaceArgs, + ], + environment: [providerWorkspaceEnvironment([runtimeSettings?.env, launchEnv])], + purpose: { + kind: "agent" as const, + agentId: context.agentId ?? "unidentified-agent", + provider: "claude", + }, + signal: spawnOptions.signal, + }))(), + ); + onChildProcess?.(child); + if (typeof options.stderr === "function") { + child.stderr.on("data", (chunk: Buffer | string) => options.stderr?.(chunk.toString())); + } + return child; + } const child = spawnProcess(command, args, { cwd: spawnOptions.cwd, ...(selfNodeCommand diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 7806c51489..83879ab45f 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -14,6 +14,7 @@ import type { AgentSessionConfig, AgentSlashCommand, AgentStreamEvent, + ProviderWorkspace, } from "../agent-sdk-types.js"; import { buildCodexAppServerEnv, @@ -109,6 +110,66 @@ function createConfig(overrides: Partial = {}): AgentSession }; } +function createCodexProviderWorkspace( + child: ChildProcessWithoutNullStreams, + options: { prompt?: string; onLaunch?: () => void } = {}, +): ProviderWorkspace { + return { + cwd: ".", + async resolveExecutable() { + return "codex"; + }, + async launch() { + options.onLaunch?.(); + return child; + }, + launchDeferred() { + throw new Error("not used"); + }, + async runProbe() { + return { stdout: "codex-cli 99.0.0", stderr: "" }; + }, + async readWorkspaceText() { + throw new Error("not used"); + }, + async writeWorkspaceText() { + throw new Error("not used"); + }, + async listState(statePath) { + return { + path: statePath, + entries: options.prompt + ? [ + { + name: "selected.md", + path: ".codex/prompts/selected.md", + kind: "file" as const, + size: options.prompt.length, + modifiedAt: "2026-08-11T00:00:00.000Z", + }, + ] + : [], + }; + }, + async readStateText() { + if (options.prompt !== undefined) return options.prompt; + throw new Error("not used"); + }, + async findStateFile() { + return null; + }, + async materializeStateFile() { + throw new Error("not used"); + }, + async removeStateFile() { + throw new Error("not used"); + }, + allowsHostService() { + return false; + }, + }; +} + function createSession( configOverrides: Partial = {}, options: { goalsEnabled?: boolean; autoReviewEnabled?: boolean } = {}, @@ -1200,6 +1261,27 @@ describe("Codex app-server provider", () => { appServer.assertNoErrors(); }); + test("unarchives a selected Codex thread through the bound launch capability", async () => { + const appServer = createFakeCodexAppServer({ + "thread/unarchive": () => ({ thread: { id: "selected-thread" } }), + }); + let launchCalls = 0; + const workspace = createCodexProviderWorkspace(appServer.child, { + onLaunch: () => { + launchCalls += 1; + }, + }); + const provider = new CodexAppServerAgentClient(createTestLogger()); + + await provider.unarchiveNativeSession( + { provider: "codex", sessionId: "selected-thread" }, + { agentId: "selected-agent", workspace }, + ); + + expect(launchCalls).toBe(1); + appServer.assertNoErrors(); + }); + test("unarchives a persisted Codex thread using sessionId when nativeHandle is absent", async () => { const threadRequests: Array<{ method: string; params: unknown }> = []; const appServer = createFakeCodexAppServer({ @@ -1735,6 +1817,108 @@ describe("Codex app-server provider", () => { }); }); + test("lists selected-runtime custom prompts through provider state access", async () => { + const appServer = createFakeCodexAppServer(); + const workspace = createCodexProviderWorkspace(appServer.child, { + prompt: "---\ndescription: Runtime prompt\nargument-hint: topic\n---\nExplain $ARGUMENTS", + }); + const provider = new CodexAppServerAgentClient(createTestLogger()); + const session = await provider.createSession(createConfig({ cwd: "/host/not-selected" }), { + agentId: "selected-codex", + workspace, + }); + + await expect(session.listCommands?.()).resolves.toContainEqual({ + name: "prompts:selected", + description: "Runtime prompt", + argumentHint: "topic", + kind: "command", + }); + + await session.close(); + appServer.assertNoErrors(); + }); + + test("fails selected-runtime custom prompts closed when provider state access fails", async () => { + const appServer = createFakeCodexAppServer(); + const workspace = createCodexProviderWorkspace(appServer.child); + workspace.listState = async () => { + throw new Error("runtime paused"); + }; + const provider = new CodexAppServerAgentClient(createTestLogger()); + const session = await provider.createSession(createConfig({ cwd: "/host/not-selected" }), { + agentId: "selected-codex-failure", + workspace, + }); + + await expect(session.listCommands?.()).rejects.toThrow("runtime paused"); + + await session.close(); + appServer.assertNoErrors(); + }); + + test("rejects a selected-runtime image turn before turn/start when state materialization fails", async () => { + const turnStart = vi.fn(() => ({})); + const appServer = createFakeCodexAppServer({ "turn/start": turnStart }); + const workspace = createCodexProviderWorkspace(appServer.child); + workspace.materializeStateFile = async () => { + throw new Error("runtime paused while materializing image"); + }; + const session = await createProviderWithFakeAppServer(appServer).createSession( + createConfig({ cwd: "/host/not-selected" }), + { agentId: "selected-codex-image-failure", workspace }, + ); + + await expect( + session.startTurn([{ type: "image", mimeType: "image/png", data: ONE_BY_ONE_PNG_BASE64 }]), + ).rejects.toThrow("runtime paused while materializing image"); + expect(turnStart).not.toHaveBeenCalled(); + + await session.close(); + appServer.assertNoErrors(); + }); + + test("rejects a selected-runtime custom prompt turn before turn/start when listing fails", async () => { + const turnStart = vi.fn(() => ({})); + const appServer = createFakeCodexAppServer({ "turn/start": turnStart }); + const workspace = createCodexProviderWorkspace(appServer.child); + workspace.listState = async () => { + throw new Error("runtime paused while listing prompts"); + }; + const session = await createProviderWithFakeAppServer(appServer).createSession( + createConfig({ cwd: "/host/not-selected" }), + { agentId: "selected-codex-prompt-failure", workspace }, + ); + + await expect(session.startTurn("/prompts:selected")).rejects.toThrow( + "runtime paused while listing prompts", + ); + expect(turnStart).not.toHaveBeenCalled(); + + await session.close(); + appServer.assertNoErrors(); + }); + + test("keeps a genuinely unknown selected-runtime prompt as plain prompt text", async () => { + const turnStart = vi.fn(() => ({})); + const appServer = createFakeCodexAppServer({ "turn/start": turnStart }); + const workspace = createCodexProviderWorkspace(appServer.child); + const session = await createProviderWithFakeAppServer(appServer).createSession( + createConfig({ cwd: "/host/not-selected" }), + { agentId: "selected-codex-unknown-prompt", workspace }, + ); + + await session.startTurn("/prompts:missing keep literal"); + + expect(turnStart).toHaveBeenCalledWith( + expect.objectContaining({ + input: [expect.objectContaining({ type: "text", text: "/prompts:missing keep literal" })], + }), + ); + await session.close(); + appServer.assertNoErrors(); + }); + test("deduplicates Codex skill slash commands returned from multiple skill roots", async () => { const commands = await listCommandsFromFakeCodex([ { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 1a51434305..7c345ddae3 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -33,6 +33,7 @@ import { type ImportProviderSessionInput, type ListImportableSessionsOptions, type ProviderCatalog, + type ProviderWorkspace, type ProviderRefreshContext, type ResolveAgentDefaultModeInput, } from "../agent-sdk-types.js"; @@ -106,6 +107,14 @@ import { CodexProviderOptionsSchema, type CodexProviderOptions, } from "./codex/options.js"; +import { + providerWorkspaceEnvironment, + providerWorkspaceFromCatalogOptions, + rejectSelectedProviderWorkspaceFailure, + resolveWorkspaceCommand, + runWorkspaceProviderCommand, + spawnWorkspaceProviderProcess, +} from "./workspace/index.js"; function assertChildWithPipes( child: ChildProcess, @@ -498,11 +507,20 @@ export async function findDefaultCodexBinary(): Promise { return (await findExecutable("codex")) ?? (await findCodexMicrosoftStoreBinary()); } -async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): Promise<{ +async function resolveCodexLaunchPrefix( + runtimeSettings?: ProviderRuntimeSettings, + workspace?: ProviderWorkspace, +): Promise<{ command: string; args: string[]; }> { const launch = await resolveCodexLaunch(runtimeSettings); + if (workspace) { + return { + command: await resolveWorkspaceCommand(workspace, launch.command), + args: launch.args, + }; + } const availability = await checkCodexLaunchAvailable(launch); if (!availability.available) { throw new Error( @@ -646,13 +664,19 @@ function parseFrontMatter(markdown: string): { return { frontMatter, body }; } -async function listCodexCustomPrompts(): Promise { +async function listCodexCustomPrompts(workspace?: ProviderWorkspace): Promise { const codexHome = resolveCodexHomeDir(); const promptsDir = path.join(codexHome, "prompts"); - let entries: Dirent[]; + let entries: Array<{ name: string; isFile(): boolean }>; try { - entries = await fs.readdir(promptsDir, { withFileTypes: true }); - } catch { + entries = workspace + ? (await workspace.listState(".codex/prompts")).entries.map((entry) => ({ + name: entry.name, + isFile: () => entry.kind === "file", + })) + : await fs.readdir(promptsDir, { withFileTypes: true }); + } catch (error) { + rejectSelectedProviderWorkspaceFailure(workspace, error, { allowMissingState: true }); return []; } @@ -662,11 +686,16 @@ async function listCodexCustomPrompts(): Promise { const parsedCommands = await Promise.all( mdEntries.map(async (entry): Promise => { const name = entry.name.slice(0, -".md".length); - const fullPath = path.join(promptsDir, entry.name); + const fullPath = workspace + ? `.codex/prompts/${entry.name}` + : path.join(promptsDir, entry.name); let content: string; try { - content = await fs.readFile(fullPath, "utf8"); - } catch { + content = workspace + ? await workspace.readStateText(fullPath) + : await fs.readFile(fullPath, "utf8"); + } catch (error) { + rejectSelectedProviderWorkspaceFailure(workspace, error, { allowMissingState: true }); return null; } const parsed = parseFrontMatter(content); @@ -3039,6 +3068,8 @@ type CodexAppServerUserInput = export async function codexAppServerTurnInputFromPrompt( prompt: CodexPromptInput, logger: Logger, + workspace?: ProviderWorkspace, + onMaterializedStateFile?: (path: string) => void, ): Promise { if (typeof prompt === "string") { return [toCodexTextInput(prompt)]; @@ -3059,12 +3090,24 @@ export async function codexAppServerTurnInputFromPrompt( } if (block.type === "image") { try { - const filePath = materializeProviderImage({ - data: block.data, - mimeType: block.mimeType, - }).path; + const selectedImageData = block.data.match(/^data:[^;]+;base64,(.*)$/)?.[1] ?? block.data; + const stateFile = workspace + ? await workspace.materializeStateFile({ + name: "attachment.bin", + content: selectedImageData, + encoding: "base64", + }) + : null; + const filePath = stateFile + ? stateFile.path + : materializeProviderImage({ + data: block.data, + mimeType: block.mimeType, + }).path; + if (stateFile) onMaterializedStateFile?.(stateFile.statePath); output.push({ type: "localImage", path: filePath }); } catch (error) { + rejectSelectedProviderWorkspaceFailure(workspace, error); const message = error instanceof Error ? error.message : String(error); logger.warn({ message }, "Failed to write Codex image attachment"); output.push({ @@ -3266,6 +3309,7 @@ export class CodexAppServerAgentSession implements AgentSession { name: string; } | null = null; private cachedSkills: Array<{ name: string; description: string; path: string }> | null = null; + private readonly materializedStateFiles = new Set(); constructor( config: AgentSessionConfig, @@ -3278,6 +3322,7 @@ export class CodexAppServerAgentSession implements AgentSession { private readonly autoReviewEnabled: boolean = false, private readonly agentId?: string, private readonly initialResumePurpose: "interactive" | "history" = "interactive", + private readonly workspace?: ProviderWorkspace, ) { this.logger = logger.child({ module: "agent", @@ -3819,6 +3864,7 @@ export class CodexAppServerAgentSession implements AgentSession { const commands = await this.listCommands(); return commands.some((command) => command.name === parsed.commandName) ? parsed : null; } catch (error) { + rejectSelectedProviderWorkspaceFailure(this.workspace, error); this.logger.warn( { err: error, commandName: parsed.commandName }, "Failed to resolve slash command; falling back to plain prompt input", @@ -3833,9 +3879,12 @@ export class CodexAppServerAgentSession implements AgentSession { ): Promise { if (commandName.startsWith("prompts:")) { const promptName = commandName.slice("prompts:".length); - const codexHome = resolveCodexHomeDir(); - const promptPath = path.join(codexHome, "prompts", `${promptName}.md`); - const raw = await fs.readFile(promptPath, "utf8"); + const promptPath = this.workspace + ? `.codex/prompts/${promptName}.md` + : path.join(resolveCodexHomeDir(), "prompts", `${promptName}.md`); + const raw = this.workspace + ? await this.workspace.readStateText(promptPath) + : await fs.readFile(promptPath, "utf8"); const parsed = parseFrontMatter(raw); return expandCodexCustomPrompt(parsed.body, args); } @@ -4517,6 +4566,14 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingForegroundTurnIdentification?.resolve(null); this.pendingForegroundTurnIdentification = null; await this.disposeClient(); + if (this.workspace) { + await Promise.all( + [...this.materializedStateFiles].map((statePath) => + this.workspace!.removeStateFile(statePath), + ), + ); + this.materializedStateFiles.clear(); + } this.currentThreadId = null; } @@ -4544,7 +4601,7 @@ export class CodexAppServerAgentSession implements AgentSession { } async listCommands(): Promise { - const prompts = await listCodexCustomPrompts(); + const prompts = await listCodexCustomPrompts(this.workspace); if (!this.connected) { await this.connect(); } else { @@ -4557,7 +4614,7 @@ export class CodexAppServerAgentSession implements AgentSession { kind: "skill" as const, })); const fallbackSkills = - this.cachedSkills === null + this.cachedSkills === null && !this.workspace ? await listCodexSkills(this.config.cwd, this.deps.workspaceGitService) : []; const builtin: AgentSlashCommand[] = [ @@ -4834,7 +4891,12 @@ export class CodexAppServerAgentSession implements AgentSession { if (typeof prompt === "string") { return [toCodexTextInput(prompt)]; } - return await codexAppServerTurnInputFromPrompt(prompt, this.logger); + return await codexAppServerTurnInputFromPrompt( + prompt, + this.logger, + this.workspace, + (statePath) => this.materializedStateFiles.add(statePath), + ); } private emitEvent(event: AgentStreamEvent): void { @@ -6572,6 +6634,8 @@ export class CodexAppServerAgentClient implements AgentClient { readonly capabilities = CODEX_APP_SERVER_CAPABILITIES; private goalsEnabledPromise: Promise | null = null; private autoReviewEnabledPromise: Promise | null = null; + private readonly goalsEnabledByRuntime = new WeakMap>(); + private readonly autoReviewEnabledByRuntime = new WeakMap>(); constructor( private readonly logger: Logger, @@ -6589,7 +6653,14 @@ export class CodexAppServerAgentClient implements AgentClient { }; } - private resolveGoalsEnabled(): Promise { + private resolveGoalsEnabled(workspace?: ProviderWorkspace): Promise { + if (workspace) { + const cached = this.goalsEnabledByRuntime.get(workspace); + if (cached) return cached; + const pending = this.resolveVersionCapability(workspace, CODEX_GOALS_MIN_VERSION, "goals"); + this.goalsEnabledByRuntime.set(workspace, pending); + return pending; + } if (!this.goalsEnabledPromise) { this.goalsEnabledPromise = (async () => { try { @@ -6614,7 +6685,21 @@ export class CodexAppServerAgentClient implements AgentClient { return this.goalsEnabledPromise; } - private resolveAutoReviewEnabled(signal?: AbortSignal): Promise { + private resolveAutoReviewEnabled( + workspace?: ProviderWorkspace, + signal?: AbortSignal, + ): Promise { + if (workspace) { + const cached = this.autoReviewEnabledByRuntime.get(workspace); + if (cached) return cached; + const pending = this.resolveVersionCapability( + workspace, + CODEX_AUTO_REVIEW_MIN_VERSION, + "auto-review", + ); + this.autoReviewEnabledByRuntime.set(workspace, pending); + return pending; + } if (signal) return this.probeAutoReviewEnabled(signal); if (!this.autoReviewEnabledPromise) { this.autoReviewEnabledPromise = this.probeAutoReviewEnabled(); @@ -6643,9 +6728,9 @@ export class CodexAppServerAgentClient implements AgentClient { private async spawnAppServer( launchEnv?: Record, - options?: { goalsEnabled?: boolean; agentId?: string }, + options?: { goalsEnabled?: boolean; agentId?: string; workspace?: ProviderWorkspace }, ): Promise { - const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings); + const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings, options?.workspace); const args = [...launchPrefix.args, "app-server"]; if (options?.goalsEnabled) { args.push("--enable", "goals"); @@ -6659,6 +6744,16 @@ export class CodexAppServerAgentClient implements AgentClient { }, "provider.codex.spawn", ); + if (options?.workspace) { + return await spawnWorkspaceProviderProcess({ + workspace: options.workspace, + argv: [launchPrefix.command, ...args], + env: providerWorkspaceEnvironment([this.runtimeSettings?.env, launchEnv]), + purpose: options.agentId + ? { kind: "agent", agentId: options.agentId, provider: CODEX_PROVIDER } + : { kind: "provider-probe", provider: CODEX_PROVIDER }, + }); + } const child = spawnProcess(launchPrefix.command, args, { detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], @@ -6671,6 +6766,26 @@ export class CodexAppServerAgentClient implements AgentClient { return child; } + private async resolveVersionCapability( + workspace: ProviderWorkspace, + minimum: readonly [number, number, number], + feature: string, + ): Promise { + try { + const launch = await resolveCodexLaunchPrefix(this.runtimeSettings, workspace); + const { stdout, stderr } = await runWorkspaceProviderCommand({ + workspace, + argv: [launch.command, ...launch.args, "--version"], + env: this.runtimeSettings?.env, + provider: CODEX_PROVIDER, + }); + return codexVersionAtLeast(`${stdout}\n${stderr}`, minimum); + } catch (error) { + this.logger.warn({ err: error, feature }, "Failed to probe workspace Codex version gate"); + return false; + } + } + async createSession( config: AgentSessionConfig, launchContext?: AgentLaunchContext, @@ -6683,20 +6798,30 @@ export class CodexAppServerAgentClient implements AgentClient { // TODO: Honor persistSession=false if app-server adds support, or route // utility generations through `codex exec --ephemeral` in a larger change. } - const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER }; - const goalsEnabled = await this.resolveGoalsEnabled(); - const autoReviewEnabled = await this.resolveAutoReviewEnabled(); + const sessionConfig: AgentSessionConfig = { + ...config, + provider: CODEX_PROVIDER, + cwd: launchContext?.workspace?.cwd ?? config.cwd, + }; + const goalsEnabled = await this.resolveGoalsEnabled(launchContext?.workspace); + const autoReviewEnabled = await this.resolveAutoReviewEnabled(launchContext?.workspace); const session = new CodexAppServerAgentSession( sessionConfig, null, this.logger, () => - this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }), + this.spawnAppServer(launchContext?.env, { + goalsEnabled, + agentId: launchContext?.agentId, + workspace: launchContext?.workspace, + }), this.sessionDeps(), options?.persistSession === false, goalsEnabled, autoReviewEnabled, launchContext?.agentId, + "interactive", + launchContext?.workspace, ); await session.connect(); return session; @@ -6715,20 +6840,26 @@ export class CodexAppServerAgentClient implements AgentClient { provider: CODEX_PROVIDER, cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(), }; - const goalsEnabled = await this.resolveGoalsEnabled(); - const autoReviewEnabled = await this.resolveAutoReviewEnabled(); + merged.cwd = launchContext?.workspace?.cwd ?? merged.cwd; + const goalsEnabled = await this.resolveGoalsEnabled(launchContext?.workspace); + const autoReviewEnabled = await this.resolveAutoReviewEnabled(launchContext?.workspace); const session = new CodexAppServerAgentSession( merged, handle, this.logger, () => - this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }), + this.spawnAppServer(launchContext?.env, { + goalsEnabled, + agentId: launchContext?.agentId, + workspace: launchContext?.workspace, + }), this.sessionDeps(), false, goalsEnabled, autoReviewEnabled, launchContext?.agentId, options?.purpose ?? "interactive", + launchContext?.workspace, ); await session.connect(); return session; @@ -6737,7 +6868,7 @@ export class CodexAppServerAgentClient implements AgentClient { async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { - const child = await this.spawnAppServer(); + const child = await this.spawnAppServer(undefined, { workspace: options?.workspace }); const client = this.deps._createCodexClient?.(child, this.logger, () => ({})) ?? new CodexAppServerClient(child, this.logger); @@ -6750,15 +6881,18 @@ export class CodexAppServerAgentClient implements AgentClient { // thread/list returns the cheap `cwd` field. Fetch a wider window when // filtering since most threads will be from other cwds, then keep the // local realpath-aware filter for symlink-equivalent workspace paths. - const listLimit = options?.cwd ? Math.max(limit, 50) : limit; + const requestedCwd = options?.workspace?.cwd ?? options?.cwd; + const listLimit = requestedCwd ? Math.max(limit, 50) : limit; const response = toObjectRecord( await client.request("thread/list", { limit: listLimit, - ...(options?.cwd ? { cwd: options.cwd } : {}), + ...(requestedCwd ? { cwd: requestedCwd } : {}), }), ); const allThreads = Array.isArray(response?.data) ? response.data.filter(isRecord) : []; - const threads = filterCodexThreadsByCwd(allThreads, options?.cwd); + const threads = options?.workspace + ? allThreads + : filterCodexThreadsByCwd(allThreads, options?.cwd); return threads.slice(0, limit).map((thread) => { const threadId = typeof thread.id === "string" ? thread.id : ""; const cwd = typeof thread.cwd === "string" ? thread.cwd : process.cwd(); @@ -6793,13 +6927,14 @@ export class CodexAppServerAgentClient implements AgentClient { } async fetchCatalog( - _options: FetchCatalogOptions, + options: FetchCatalogOptions, context?: ProviderRefreshContext, ): Promise { + const workspace = providerWorkspaceFromCatalogOptions(options); const [models, autoReviewEnabled] = await Promise.all([ - this.fetchModelsFromAppServer(context), + this.fetchModelsFromAppServer(workspace, context), runProviderRefreshActivity(context, "version", () => - this.resolveAutoReviewEnabled(context?.signal), + this.resolveAutoReviewEnabled(workspace, context?.signal), ), ]); return { @@ -6812,12 +6947,13 @@ export class CodexAppServerAgentClient implements AgentClient { } async resolveDefaultModeId(input: ResolveAgentDefaultModeInput): Promise { - return (await this.resolveAutoReviewEnabled(input.signal)) + return (await this.resolveAutoReviewEnabled(undefined, input.signal)) ? "auto-review" : DEFAULT_CODEX_MODE_ID; } private async fetchModelsFromAppServer( + workspace?: ProviderWorkspace, context?: ProviderRefreshContext, ): Promise { // Codex model/list is global to the app server in this flow; cwd/force are intentionally ignored. @@ -6833,7 +6969,7 @@ export class CodexAppServerAgentClient implements AgentClient { try { await runProviderRefreshActivity(context, "app-server.start", async () => { - const child = await this.spawnAppServer(); + const child = await this.spawnAppServer(undefined, { workspace }); client = new CodexAppServerClient(child, this.logger); if (context?.signal.aborted) await dispose(); }); @@ -6870,11 +7006,16 @@ export class CodexAppServerAgentClient implements AgentClient { } } - async archiveNativeSession(handle: AgentPersistenceHandle): Promise { + async archiveNativeSession( + handle: AgentPersistenceHandle, + launchContext?: AgentLaunchContext, + ): Promise { const threadId = handle.nativeHandle ?? handle.sessionId; if (!threadId) return; - const child = await this.spawnAppServer(); + const child = await this.spawnAppServer(launchContext?.env, { + workspace: launchContext?.workspace, + }); const client = new CodexAppServerClient(child, this.logger); try { @@ -6886,11 +7027,16 @@ export class CodexAppServerAgentClient implements AgentClient { } } - async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise { + async unarchiveNativeSession( + handle: AgentPersistenceHandle, + launchContext?: AgentLaunchContext, + ): Promise { const threadId = handle.nativeHandle ?? handle.sessionId; if (!threadId) return; - const child = await this.spawnAppServer(); + const child = await this.spawnAppServer(launchContext?.env, { + workspace: launchContext?.workspace, + }); const client = new CodexAppServerClient(child, this.logger); try { @@ -6913,8 +7059,19 @@ export class CodexAppServerAgentClient implements AgentClient { } } - async isAvailable(): Promise { + async isAvailable(options?: FetchCatalogOptions): Promise { const launch = await resolveCodexLaunch(this.runtimeSettings); + if (options) { + try { + const workspace = providerWorkspaceFromCatalogOptions(options); + if (workspace) { + await workspace.resolveExecutable(launch.command); + return true; + } + } catch { + return false; + } + } const availability = await checkCodexLaunchAvailable(launch); return availability.available; } diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index 095d4e10a8..db1876ee75 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -1,7 +1,7 @@ import type { Logger } from "pino"; import { z } from "zod"; -import type { AgentCapabilityFlags } from "../agent-sdk-types.js"; +import type { AgentCapabilityFlags, FetchCatalogOptions } from "../agent-sdk-types.js"; import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js"; import { ACPAgentClient, @@ -84,14 +84,10 @@ export class GenericACPAgentClient extends ACPAgentClient { this.diagnosticPhaseTimeoutMs = options.diagnosticPhaseTimeoutMs; } - protected override async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> { - return { - command: this.command[0], - args: this.command.slice(1), - }; - } - - override async isAvailable(): Promise { + override async isAvailable(options?: FetchCatalogOptions): Promise { + if (options?.scope === "workspace" && options.workspace) { + return super.isAvailable(options); + } const launch = await this.resolveConfiguredLaunch(); const availability = await checkProviderLaunchAvailable(launch); return availability.available; diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index db996cf701..defee7495e 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -33,6 +33,7 @@ import { type ImportProviderSessionInput, type ListImportableSessionsOptions, type ProviderCatalog, + type ProviderWorkspace, type ProviderRefreshContext, type ToolCallDetail, } from "../../agent-sdk-types.js"; @@ -71,6 +72,7 @@ import { shouldDisplayOmpCustomMessage } from "./custom-message.js"; import { getUserMessageText } from "./message-history.js"; import { mapOmpSystemNoticeToToolCall } from "./system-notice.js"; import { materializeProviderImage } from "../provider-image-output.js"; +import { providerWorkspaceFromCatalogOptions } from "../workspace/index.js"; import { OmpCliRuntime } from "./cli-runtime.js"; import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js"; import type { OmpRuntime, OmpRuntimeSession, OmpStartSessionInput } from "./runtime.js"; @@ -186,6 +188,7 @@ interface OmpAgentSessionOptions { noTurnScheduler?: OmpNoTurnScheduler; usagePollScheduler?: OmpUsagePollScheduler; paseoTools?: PaseoToolCatalog; + workspace?: ProviderWorkspace; /** * When false (resumed sessions), replayed session events are dropped until * the first prompt or agent_start so history is not re-emitted as live @@ -310,7 +313,13 @@ function ompModelSupportsImageInput(model: OmpModel | null | undefined): boolean return model?.input?.includes("image") === true; } -function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string { +function renderTextOnlyImageHint( + image: { data: string; mimeType: string }, + selectedWorkspace: boolean, +): string { + if (selectedWorkspace) { + return "[Image attachment omitted: selected runtime model has no image input]"; + } try { const materialized = materializeProviderImage({ data: image.data, @@ -324,7 +333,7 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str function convertPromptInput( prompt: AgentPromptInput, - options: { model: OmpModel | null | undefined }, + options: { model: OmpModel | null | undefined; workspace?: ProviderWorkspace }, ): OmpPromptPayload { if (typeof prompt === "string") { return { text: prompt }; @@ -348,7 +357,7 @@ function convertPromptInput( mimeType: block.mimeType, }); } else { - textParts.push(renderTextOnlyImageHint(block)); + textParts.push(renderTextOnlyImageHint(block, Boolean(options.workspace))); } continue; } @@ -447,6 +456,8 @@ function buildResumeStartInput(input: { input.resumeConfig.config.systemPrompt, input.resumeConfig.config.daemonAppendSystemPrompt, ), + workspace: input.launchContext?.workspace, + agentId: input.launchContext?.agentId, }; } @@ -867,6 +878,7 @@ export class OmpAgentSession implements AgentSession { private readonly subagentCardTracker: OmpSubagentCardTracker; private lastTodoItem: Extract | null = null; private state: OmpSessionState; + private readonly workspace?: ProviderWorkspace; private readonly currentModeId: string | null; private readonly providerIdleScheduler: OmpProviderIdleScheduler; private readonly noTurnScheduler: OmpNoTurnScheduler; @@ -882,6 +894,7 @@ export class OmpAgentSession implements AgentSession { this.currentModeId = options.currentModeId ?? null; this.logger = options.logger; this.paseoTools = options.paseoTools; + this.workspace = options.workspace; this.live = options.live ?? true; this.providerIdleScheduler = options.providerIdleScheduler ?? createOmpProviderIdleScheduler(); this.noTurnScheduler = options.noTurnScheduler ?? createOmpNoTurnScheduler(); @@ -952,7 +965,10 @@ export class OmpAgentSession implements AgentSession { throw new Error("An OMP turn is already active"); } - const payload = convertPromptInput(prompt, { model: this.state.model }); + const payload = convertPromptInput(prompt, { + model: this.state.model, + workspace: this.workspace, + }); const turnId = randomUUID(); this.live = true; this.activeTurnId = turnId; @@ -1030,6 +1046,7 @@ export class OmpAgentSession implements AgentSession { sessionFile: this.state.sessionFile, runtimeSession: this.runtimeSession, provider: this.provider, + workspace: this.workspace, }); for (const item of mapOmpTodoState(this.state)) { yield { @@ -2232,7 +2249,9 @@ export class OmpAgentClient implements AgentClient { ): Promise { const launchMode = this.resolveLaunchMode(config.modeId); const runtimeSession = await this.runtime.startSession({ - cwd: config.cwd, + cwd: launchContext?.workspace?.cwd ?? config.cwd, + workspace: launchContext?.workspace, + agentId: launchContext?.agentId, protocolMode: "rpc-ui", model: config.model, thinkingOptionId: normalizeOmpThinkingOption(config.thinkingOptionId) ?? undefined, @@ -2255,6 +2274,7 @@ export class OmpAgentClient implements AgentClient { noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, paseoTools: launchContext?.paseoTools, + workspace: launchContext?.workspace, }); } catch (error) { await runtimeSession.close().catch(() => undefined); @@ -2297,6 +2317,7 @@ export class OmpAgentClient implements AgentClient { noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, paseoTools: launchContext?.paseoTools, + workspace: launchContext?.workspace, live: false, }); } catch (error) { @@ -2310,6 +2331,8 @@ export class OmpAgentClient implements AgentClient { context?: ProviderRefreshContext, ): Promise { const launchMode = this.resolveLaunchMode(undefined); + const workspace = providerWorkspaceFromCatalogOptions(options); + const cwd = workspace?.cwd ?? (options.scope === "global" ? homedir() : options.cwd); let runtimeSession: OmpRuntimeSession | undefined; let closePromise: Promise | undefined; const closeSession = () => { @@ -2322,7 +2345,8 @@ export class OmpAgentClient implements AgentClient { try { await runProviderRefreshActivity(context, "runtime.start", async () => { runtimeSession = await this.runtime.startSession({ - cwd: options.scope === "global" ? homedir() : options.cwd, + cwd, + workspace, protocolMode: "rpc-ui", modeId: launchMode.modeId, extraArgs: launchMode.extraArgs, @@ -2353,6 +2377,7 @@ export class OmpAgentClient implements AgentClient { async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { + if (options?.workspace) return []; return await listOmpImportableSessions({ ...options, sessionDir: this.providerParams.sessionDir, @@ -2361,7 +2386,9 @@ export class OmpAgentClient implements AgentClient { } async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { - const importConfig = await readOmpImportSessionConfig(input.providerHandleId); + const importConfig = context.launchContext?.workspace + ? {} + : await readOmpImportSessionConfig(input.providerHandleId); return importSessionFromPersistence({ provider: this.provider, request: input, @@ -2371,9 +2398,16 @@ export class OmpAgentClient implements AgentClient { }); } - async isAvailable(): Promise { + async isAvailable(options?: FetchCatalogOptions): Promise { try { const launch = await this.resolveOmpLaunch(); + if (options) { + const workspace = providerWorkspaceFromCatalogOptions(options); + if (workspace) { + await workspace.resolveExecutable(launch.command); + return true; + } + } const availability = await checkProviderLaunchAvailable(launch); return availability.available; } catch { diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.ts index 97e01c937a..156bc374fe 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.ts @@ -42,6 +42,11 @@ import { type OmpSessionStats, type OmpSubagentSubscriptionLevel, } from "./rpc-types.js"; +import { + providerWorkspaceEnvironment, + resolveWorkspaceCommand, + spawnWorkspaceProviderProcess, +} from "../workspace/index.js"; const DEFAULT_OMP_COMMAND: [string, ...string[]] = [process.env.OMP_COMMAND ?? "omp"]; const DEFAULT_COMMANDS_RPC_NAME = "get_available_commands"; @@ -74,6 +79,16 @@ export class OmpCliRuntime implements OmpRuntime { session: input, }); const [command, ...args] = launch.argv; + const workspaceChild = input.workspace + ? await spawnWorkspaceProviderProcess({ + workspace: input.workspace, + argv: [await resolveWorkspaceCommand(input.workspace, command), ...args], + env: providerWorkspaceEnvironment([this.options.runtimeSettings?.env, input.env]), + purpose: input.agentId + ? { kind: "agent", agentId: input.agentId, provider: "omp" } + : { kind: "provider-probe", provider: "omp" }, + }) + : null; const processLaunch: JsonlRpcLaunch = { command, args, @@ -81,11 +96,14 @@ export class OmpCliRuntime implements OmpRuntime { env: launch.env, }; const spawn = this.spawnProcess; + let runtimeSpawn; + if (workspaceChild) runtimeSpawn = () => workspaceChild; + else if (spawn) runtimeSpawn = () => spawn(launch); const processOptions = { launch: processLaunch, logger: this.options.logger, diagnosticName: "OMP RPC", - ...(spawn ? { spawn: () => spawn(launch) } : {}), + ...(runtimeSpawn ? { spawn: runtimeSpawn } : {}), }; const process = new JsonlRpcProcess(processOptions); const handleAbort = () => void process.close(input.signal?.reason).catch(() => undefined); diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index db67f0cbe3..57e589baf0 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -1,9 +1,9 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; -import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import type { AgentStreamEvent, ProviderWorkspace } from "../../agent-sdk-types.js"; import { streamOmpCoreHistory, type OmpCapturedUserMessageEntry } from "./message-history.js"; import type { OmpAgentMessage } from "./rpc-types.js"; import { FakeOmp } from "./test-utils/fake-omp.js"; @@ -26,7 +26,82 @@ async function collectHistory( return events; } +function stateWorkspace(content: string): ProviderWorkspace { + return { + cwd: ".", + async resolveExecutable(command) { + return command; + }, + async launch() { + throw new Error("not used"); + }, + launchDeferred() { + throw new Error("not used"); + }, + async runProbe() { + throw new Error("not used"); + }, + async readWorkspaceText() { + throw new Error("not used"); + }, + async writeWorkspaceText() { + throw new Error("not used"); + }, + async listState(path) { + return { path, entries: [] }; + }, + async readStateText() { + return content; + }, + async findStateFile() { + return null; + }, + async materializeStateFile() { + throw new Error("not used"); + }, + async removeStateFile() { + throw new Error("not used"); + }, + allowsHostService() { + return false; + }, + }; +} + describe("OMP history mapper", () => { + test("reads a selected session transcript through provider state access", async () => { + const workspace = stateWorkspace( + JSON.stringify({ + type: "message", + id: "selected-user", + parentId: null, + message: { role: "user", content: "selected runtime history" }, + }), + ); + const readStateText = vi.spyOn(workspace, "readStateText"); + const events: AgentStreamEvent[] = []; + + for await (const event of streamOmpHistory({ + sessionFile: "/runtime/home/.omp/agent/sessions/project/session.jsonl", + provider: "omp", + workspace, + })) { + events.push(event); + } + + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: { + type: "user_message", + text: "selected runtime history", + messageId: "selected-user", + }, + }), + ); + expect(readStateText).toHaveBeenCalledWith(".omp/agent/sessions/project/session.jsonl"); + }); + test("coalesces replayed subagent poll calls by target set", async () => { const events = await collectHistory([ { diff --git a/packages/server/src/server/agent/providers/omp/history.ts b/packages/server/src/server/agent/providers/omp/history.ts index 7147e35e59..af8a794268 100644 --- a/packages/server/src/server/agent/providers/omp/history.ts +++ b/packages/server/src/server/agent/providers/omp/history.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; -import { basename, extname, join } from "node:path"; -import type { AgentProvider, AgentStreamEvent } from "../../agent-sdk-types.js"; +import { basename, extname, isAbsolute, join } from "node:path"; +import type { AgentProvider, AgentStreamEvent, ProviderWorkspace } from "../../agent-sdk-types.js"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; import { OmpHistoryMapper, type OmpCapturedUserMessageEntry } from "./message-history.js"; import type { OmpAgentMessage } from "./rpc-types.js"; @@ -46,6 +46,7 @@ export async function* streamOmpHistory(input: { runtimeSession?: OmpRuntimeSession; provider: AgentProvider; visitedSessionFiles?: Set; + workspace?: ProviderWorkspace; }): AsyncGenerator { if (!input.sessionFile) { return; @@ -60,6 +61,7 @@ export async function* streamOmpHistory(input: { entries = await readActiveOmpEntryChain( input.sessionFile, input.runtimeSession?.activeBranchEntryId, + input.workspace, ); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { @@ -87,7 +89,12 @@ export async function* streamOmpHistory(input: { } } for (const transcript of readSubagentTranscripts(messages, input.sessionFile)) { - yield* replaySubagentTranscript(transcript, input.provider, visitedSessionFiles); + yield* replaySubagentTranscript( + transcript, + input.provider, + visitedSessionFiles, + input.workspace, + ); } } @@ -95,13 +102,16 @@ async function* replaySubagentTranscript( transcript: OmpSubagentTranscript, provider: AgentProvider, visitedSessionFiles: Set, + workspace?: ProviderWorkspace, ): AsyncGenerator { - const childEntries = await readActiveOmpEntryChain(transcript.sessionFile).catch( - (error: unknown) => { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; - throw error; - }, - ); + const childEntries = await readActiveOmpEntryChain( + transcript.sessionFile, + undefined, + workspace, + ).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + }); const resolvedModel = extractOmpSubagentModel(childEntries); const firstTimestamp = normalizeProviderReplayTimestamp(childEntries[0]?.timestamp); yield subagentUpsert(transcript, provider, "running", firstTimestamp, resolvedModel); @@ -109,6 +119,7 @@ async function* replaySubagentTranscript( sessionFile: transcript.sessionFile, provider, visitedSessionFiles, + workspace, })) { if (event.type === "timeline") { yield { @@ -293,8 +304,11 @@ function stripExtension(filePath: string): string { export async function readActiveOmpEntryChain( sessionFile: string, activeEntryId?: string, + workspace?: ProviderWorkspace, ): Promise { - const content = await readFile(sessionFile, "utf8"); + const content = workspace + ? await workspace.readStateText(toOmpProviderStatePath(sessionFile)) + : await readFile(sessionFile, "utf8"); const entries = content.split("\n").flatMap((line) => { if (!line.trim()) return []; try { @@ -320,6 +334,17 @@ export async function readActiveOmpEntryChain( return chain.toReversed(); } +function toOmpProviderStatePath(sessionFile: string): string { + if (!isAbsolute(sessionFile)) return sessionFile.split("\\").join("/"); + const normalized = sessionFile.split("\\").join("/"); + const marker = "/.omp/"; + const markerIndex = normalized.indexOf(marker); + if (markerIndex < 0) { + throw new Error(`OMP selected-runtime session path is outside provider state: ${sessionFile}`); + } + return normalized.slice(markerIndex + 1); +} + function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null { const message = entry.message; if (message && typeof message.role === "string") { diff --git a/packages/server/src/server/agent/providers/omp/runtime.ts b/packages/server/src/server/agent/providers/omp/runtime.ts index dc8001e3ec..219939bc26 100644 --- a/packages/server/src/server/agent/providers/omp/runtime.ts +++ b/packages/server/src/server/agent/providers/omp/runtime.ts @@ -13,6 +13,7 @@ import type { OmpThinkingLevel, } from "./rpc-types.js"; import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; +import type { ProviderWorkspace } from "../../agent-sdk-types.js"; export interface OmpRuntimeLaunch { cwd: string; @@ -29,6 +30,8 @@ export interface OmpRuntimeLaunch { } export interface OmpStartSessionInput { + workspace?: ProviderWorkspace; + agentId?: string; cwd: string; signal?: AbortSignal; env?: Record; diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index d2eb6730fe..43c684ccb9 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -85,6 +85,7 @@ import { toDiagnosticErrorMessage, } from "./diagnostic-utils.js"; import { runProviderTurn } from "./provider-runner.js"; +import { providerWorkspaceFromCatalogOptions } from "./workspace/index.js"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { composeSystemPromptParts } from "../system-prompt.js"; import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js"; @@ -1432,6 +1433,10 @@ export class OpenCodeAgentClient implements AgentClient { options: FetchCatalogOptions, context?: ProviderRefreshContext, ): Promise { + const workspace = providerWorkspaceFromCatalogOptions(options); + if (workspace && !workspace.allowsHostService("opencode")) { + throw new Error("OpenCode is unavailable in the selected workspace runtime"); + } let acquisition: OpenCodeServerAcquisition | undefined; try { await runProviderRefreshActivity(context, "server.acquire", async () => { @@ -1490,6 +1495,7 @@ export class OpenCodeAgentClient implements AgentClient { async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { + if (options?.workspace && !options.workspace.allowsHostService("opencode")) return []; const acquisition = await this.serverManager.acquireCurrent(); const { url } = acquisition.server; const client = this.createOpenCodeClient({ @@ -1590,7 +1596,15 @@ export class OpenCodeAgentClient implements AgentClient { } } - async isAvailable(): Promise { + async isAvailable(options?: FetchCatalogOptions): Promise { + if (options) { + try { + const workspace = providerWorkspaceFromCatalogOptions(options); + if (workspace && !workspace.allowsHostService("opencode")) return false; + } catch { + return false; + } + } const launch = await resolveProviderLaunch({ commandConfig: this.runtimeSettings?.command, defaultBinary: "opencode", diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 82bd0a8232..c49de3aea2 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -36,6 +36,7 @@ import { type ImportProviderSessionInput, type ListImportableSessionsOptions, type ProviderCatalog, + type ProviderWorkspace, type ProviderRefreshContext, type ToolCallDetail, } from "../../agent-sdk-types.js"; @@ -63,6 +64,7 @@ import { type PiCapturedUserMessageEntry, } from "./history-mapper.js"; import { materializeProviderImage } from "../provider-image-output.js"; +import { providerWorkspaceFromCatalogOptions } from "../workspace/index.js"; import { PiCliRuntime } from "./cli-runtime.js"; import { revertPiConversation } from "./rewind.js"; import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js"; @@ -222,8 +224,9 @@ interface PiRpcAgentSessionOptions { initialState: PiSessionState; capabilities: AgentCapabilityFlags; currentModeId?: string | null; - cleanup?: () => void; + cleanup?: () => void | Promise; extensionTimeoutMs?: number; + workspace?: ProviderWorkspace; } interface PiResumeConfig { @@ -251,7 +254,7 @@ interface PiMcpConfigFile { interface PiTempFile { path: string; - cleanup: () => void; + cleanup: () => void | Promise; } interface PiCapturedEntry extends PiCapturedUserMessageEntry { @@ -400,7 +403,13 @@ function piModelSupportsImageInput(model: PiModel | null | undefined): boolean { return model?.input?.includes("image") === true; } -function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string { +function renderTextOnlyImageHint( + image: { data: string; mimeType: string }, + selectedWorkspace: boolean, +): string { + if (selectedWorkspace) { + return "[Image attachment omitted: selected runtime model has no image input]"; + } try { const materialized = materializeProviderImage({ data: image.data, @@ -414,7 +423,7 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str function convertPromptInput( prompt: AgentPromptInput, - options: { model: PiModel | null | undefined }, + options: { model: PiModel | null | undefined; workspace?: ProviderWorkspace }, ): PiPromptPayload { if (typeof prompt === "string") { return { text: prompt }; @@ -438,7 +447,7 @@ function convertPromptInput( mimeType: block.mimeType, }); } else { - textParts.push(renderTextOnlyImageHint(block)); + textParts.push(renderTextOnlyImageHint(block, Boolean(options.workspace))); } continue; } @@ -533,6 +542,8 @@ function buildResumeStartInput(input: { thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined, mcpConfigPath: input.mcpConfig?.path, extensionPaths: input.paseoExtension ? [input.paseoExtension.path] : undefined, + workspace: input.launchContext?.workspace, + agentId: input.launchContext?.agentId, }; } @@ -622,12 +633,11 @@ function createPiMcpConfigFile( }; } -function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { - const dir = mkdtempSync(join(tmpdir(), "paseo-pi-extension-")); - const filePath = join(dir, "paseo-integration.mjs"); - writeFileSync( - filePath, - ` +async function createPiPaseoExtensionFile( + systemPrompt?: string, + workspace?: ProviderWorkspace, +): Promise { + const content = ` function decodePayload(encoded) { return JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); } @@ -753,27 +763,47 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { }, }); } -`.trimStart(), - "utf8", - ); +`.trimStart(); + if (workspace) { + const stateFile = await workspace.materializeStateFile({ + name: "paseo-integration.mjs", + content, + }); + return { path: stateFile.path, cleanup: stateFile.remove }; + } + const dir = mkdtempSync(join(tmpdir(), "paseo-pi-extension-")); + const filePath = join(dir, "paseo-integration.mjs"); + writeFileSync(filePath, content, "utf8"); return { path: filePath, cleanup: () => rmSync(dir, { recursive: true, force: true }), }; } -function combineCleanup(cleanups: Array<(() => void) | undefined>): (() => void) | undefined { - const activeCleanups = cleanups.filter((cleanup): cleanup is () => void => Boolean(cleanup)); +function combineCleanup( + cleanups: Array<(() => void | Promise) | undefined>, +): (() => Promise) | undefined { + const activeCleanups = cleanups.filter((cleanup): cleanup is () => void | Promise => + Boolean(cleanup), + ); if (activeCleanups.length === 0) { return undefined; } - return () => { + return async () => { for (const cleanup of activeCleanups) { - cleanup(); + await cleanup(); } }; } +async function cleanupPiSessionFiles( + mcpConfig: PiMcpConfigFile | null, + paseoExtension: PiTempFile | null, +): Promise { + await mcpConfig?.cleanup(); + await paseoExtension?.cleanup(); +} + function isPiMcpAdapterCommand(command: PiRpcSlashCommand): boolean { if (command.source !== "extension" || !/^mcp(?::\d+)?$/.test(command.name)) { return false; @@ -1260,6 +1290,7 @@ export class PiRpcAgentSession implements AgentSession { this.capabilities = options.capabilities; this.provider = PI_PROVIDER; this.currentModeId = options.currentModeId ?? null; + this.workspace = options.workspace; this.cleanup = options.cleanup; this.lastKnownThinkingOptionId = normalizePiThinkingOption(options.config.thinkingOptionId) ?? @@ -1274,6 +1305,7 @@ export class PiRpcAgentSession implements AgentSession { private readonly runtimeSession: PiRuntimeSession; private readonly config: AgentSessionConfig; + private readonly workspace?: ProviderWorkspace; private readonly cleanup?: () => void; private readonly extensionTimeoutMs: number; @@ -1298,7 +1330,10 @@ export class PiRpcAgentSession implements AgentSession { throw new Error("A Pi turn is already active"); } - const payload = convertPromptInput(prompt, { model: this.state.model }); + const payload = convertPromptInput(prompt, { + model: this.state.model, + workspace: this.workspace, + }); const turnId = randomUUID(); this.activeTurnId = turnId; this.lastInterruptedTurnId = null; @@ -1531,7 +1566,7 @@ export class PiRpcAgentSession implements AgentSession { await this.runtimeSession.close(); } finally { this.rejectAllExtensionResults(new Error("Pi session closed")); - this.cleanup?.(); + await this.cleanup?.(); } } @@ -2376,14 +2411,22 @@ export class PiRpcAgentClient implements AgentClient { ...this.runtimeSettings?.env, ...launchContext?.env, }; - const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers, mcpEnv); - const paseoExtension = createPiPaseoExtensionFile( + const mcpConfig = await this.prepareMcpConfig( + config.cwd, + config.mcpServers, + mcpEnv, + launchContext?.workspace, + ); + const paseoExtension = await createPiPaseoExtensionFile( composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt), + launchContext?.workspace, ); let runtimeSession: PiRuntimeSession; try { runtimeSession = await this.runtime.startSession({ - cwd: config.cwd, + cwd: launchContext?.workspace?.cwd ?? config.cwd, + workspace: launchContext?.workspace, + agentId: launchContext?.agentId, model: config.model, thinkingOptionId: normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL, @@ -2393,8 +2436,7 @@ export class PiRpcAgentClient implements AgentClient { extensionPaths: paseoExtension ? [paseoExtension.path] : undefined, }); } catch (error) { - mcpConfig?.cleanup(); - paseoExtension?.cleanup(); + await cleanupPiSessionFiles(mcpConfig, paseoExtension); throw error; } try { @@ -2405,11 +2447,11 @@ export class PiRpcAgentClient implements AgentClient { capabilities: capabilitiesForSession(mcpConfig !== null), cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension?.cleanup]), extensionTimeoutMs: this.providerParams.extensionTimeoutMs, + workspace: launchContext?.workspace, }); } catch (error) { await runtimeSession.close().catch(() => undefined); - mcpConfig?.cleanup(); - paseoExtension?.cleanup(); + await cleanupPiSessionFiles(mcpConfig, paseoExtension); throw error; } } @@ -2435,12 +2477,14 @@ export class PiRpcAgentClient implements AgentClient { resumeConfig.cwd, resumeConfig.config.mcpServers, mcpEnv, + launchContext?.workspace, ); - const paseoExtension = createPiPaseoExtensionFile( + const paseoExtension = await createPiPaseoExtensionFile( composeSystemPromptParts( resumeConfig.config.systemPrompt, resumeConfig.config.daemonAppendSystemPrompt, ), + launchContext?.workspace, ); let runtimeSession: PiRuntimeSession; try { @@ -2454,8 +2498,7 @@ export class PiRpcAgentClient implements AgentClient { }), ); } catch (error) { - mcpConfig?.cleanup(); - paseoExtension?.cleanup(); + await cleanupPiSessionFiles(mcpConfig, paseoExtension); throw error; } try { @@ -2466,11 +2509,11 @@ export class PiRpcAgentClient implements AgentClient { capabilities: capabilitiesForSession(mcpConfig !== null), cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension?.cleanup]), extensionTimeoutMs: this.providerParams.extensionTimeoutMs, + workspace: launchContext?.workspace, }); } catch (error) { await runtimeSession.close().catch(() => undefined); - mcpConfig?.cleanup(); - paseoExtension?.cleanup(); + await cleanupPiSessionFiles(mcpConfig, paseoExtension); throw error; } } @@ -2479,6 +2522,8 @@ export class PiRpcAgentClient implements AgentClient { options: FetchCatalogOptions, context?: ProviderRefreshContext, ): Promise { + const workspace = providerWorkspaceFromCatalogOptions(options); + const cwd = workspace?.cwd ?? (options.scope === "global" ? homedir() : options.cwd); let runtimeSession: PiRuntimeSession | undefined; let closePromise: Promise | undefined; const closeSession = () => { @@ -2491,7 +2536,8 @@ export class PiRpcAgentClient implements AgentClient { try { await runProviderRefreshActivity(context, "runtime.start", async () => { runtimeSession = await this.runtime.startSession({ - cwd: options.scope === "global" ? homedir() : options.cwd, + cwd, + workspace, signal: context?.signal, }); if (context?.signal.aborted) await closeSession(); @@ -2519,6 +2565,7 @@ export class PiRpcAgentClient implements AgentClient { async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { + if (options?.workspace) return []; return await listPiImportableSessions({ ...options, sessionDir: this.providerParams.sessionDir, @@ -2527,7 +2574,9 @@ export class PiRpcAgentClient implements AgentClient { } async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { - const importConfig = await readPiImportSessionConfig(input.providerHandleId); + const importConfig = context.launchContext?.workspace + ? {} + : await readPiImportSessionConfig(input.providerHandleId); return importSessionFromPersistence({ provider: this.provider, request: input, @@ -2537,9 +2586,16 @@ export class PiRpcAgentClient implements AgentClient { }); } - async isAvailable(): Promise { + async isAvailable(options?: FetchCatalogOptions): Promise { try { const launch = await this.resolvePiLaunch(); + if (options) { + const workspace = providerWorkspaceFromCatalogOptions(options); + if (workspace) { + await workspace.resolveExecutable(launch.command); + return true; + } + } const availability = await checkProviderLaunchAvailable(launch); return availability.available; } catch { @@ -2577,10 +2633,14 @@ export class PiRpcAgentClient implements AgentClient { cwd: string, servers: Record | undefined, env: Record | undefined, + workspace?: ProviderWorkspace, ): Promise { if (!servers || Object.keys(servers).length === 0) { return null; } + if (workspace) { + throw new Error("Pi MCP configuration is unavailable in a selected workspace runtime"); + } if (!(await this.detectMcpAdapter(cwd, env))) { return null; } diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.ts index 5432b913ea..51f4c5c6f1 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.ts @@ -24,6 +24,11 @@ import type { PiSessionState, PiSessionStats, } from "./rpc-types.js"; +import { + providerWorkspaceEnvironment, + resolveWorkspaceCommand, + spawnWorkspaceProviderProcess, +} from "../workspace/index.js"; const DEFAULT_PI_COMMAND: [string, ...string[]] = [ process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi", @@ -64,6 +69,16 @@ export class PiCliRuntime implements PiRuntime { session: input, }); const [command, ...args] = launch.argv; + const workspaceChild = input.workspace + ? await spawnWorkspaceProviderProcess({ + workspace: input.workspace, + argv: [await resolveWorkspaceCommand(input.workspace, command), ...args], + env: providerWorkspaceEnvironment([this.options.runtimeSettings?.env, input.env]), + purpose: input.agentId + ? { kind: "agent", agentId: input.agentId, provider: "pi" } + : { kind: "provider-probe", provider: "pi" }, + }) + : null; const processLaunch: JsonlRpcLaunch = { command, args, @@ -71,11 +86,14 @@ export class PiCliRuntime implements PiRuntime { env: launch.env, }; const spawn = this.spawnProcess; + let runtimeSpawn; + if (workspaceChild) runtimeSpawn = () => workspaceChild; + else if (spawn) runtimeSpawn = () => spawn(launch); const processOptions = { launch: processLaunch, logger: this.options.logger, diagnosticName: "Pi RPC", - ...(spawn ? { spawn: () => spawn(launch) } : {}), + ...(runtimeSpawn ? { spawn: runtimeSpawn } : {}), }; const process = new JsonlRpcProcess(processOptions); if (input.signal?.aborted) { diff --git a/packages/server/src/server/agent/providers/pi/runtime.ts b/packages/server/src/server/agent/providers/pi/runtime.ts index 7d29fc159f..4215930546 100644 --- a/packages/server/src/server/agent/providers/pi/runtime.ts +++ b/packages/server/src/server/agent/providers/pi/runtime.ts @@ -8,6 +8,7 @@ import type { PiSessionStats, } from "./rpc-types.js"; import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; +import type { ProviderWorkspace } from "../../agent-sdk-types.js"; export interface PiRuntimeLaunch { cwd: string; @@ -26,6 +27,8 @@ export interface PiRuntimeLaunch { export interface PiStartSessionInput { cwd: string; + workspace?: ProviderWorkspace; + agentId?: string; signal?: AbortSignal; env?: Record; protocolMode?: "rpc" | "rpc-ui"; diff --git a/packages/server/src/server/agent/providers/workspace/index.ts b/packages/server/src/server/agent/providers/workspace/index.ts new file mode 100644 index 0000000000..14f7755018 --- /dev/null +++ b/packages/server/src/server/agent/providers/workspace/index.ts @@ -0,0 +1,562 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { randomUUID } from "node:crypto"; +import nodePath from "node:path"; +import { PassThrough } from "node:stream"; + +import type { ProcessEnvRecord } from "../../../paseo-env.js"; +import { createProviderEnv } from "../../provider-launch-config.js"; +import type { + BoundWorkspaceRuntime, + WorkspaceProcess, + WorkspaceProcessPurpose, + WorkspaceRuntimeProviderCapability, +} from "../../../workspace-runtime/index.js"; +import type { WorkspaceDirectory } from "@getpaseo/workspace-helper"; + +export interface ProviderPlacementPolicy { + environment: + | { type: "inherit-sanitized-host"; hostEnvironment: ProcessEnvRecord } + | { type: "isolated" }; + sharedHostProviders: ReadonlySet; +} + +export function resolveProviderPlacementPolicy(input: { + capability: WorkspaceRuntimeProviderCapability; + hostEnvironment: ProcessEnvRecord; +}): ProviderPlacementPolicy { + return { + environment: + input.capability.environment === "inherit-sanitized-host" + ? { type: "inherit-sanitized-host", hostEnvironment: input.hostEnvironment } + : { type: "isolated" }, + sharedHostProviders: input.capability.sharedHostProviders, + }; +} + +export interface ProviderWorkspaceLaunchInput { + argv: readonly [string, ...string[]]; + cwd?: string; + environment?: readonly (ProcessEnvRecord | undefined)[]; + purpose: WorkspaceProcessPurpose; + signal?: AbortSignal; +} + +export class ProviderStateNotFoundError extends Error { + constructor(readonly statePath: string) { + super(`Provider state was not found: ${statePath}`); + this.name = "ProviderStateNotFoundError"; + } +} + +export function isProviderStateNotFoundError(error: unknown): error is ProviderStateNotFoundError { + return error instanceof ProviderStateNotFoundError; +} + +/** Preserve legacy fallbacks while preventing selected workspace capability failures from hiding. */ +export function rejectSelectedProviderWorkspaceFailure( + workspace: ProviderWorkspace | undefined, + error: unknown, + options: { allowMissingState?: boolean } = {}, +): void { + if (!workspace) return; + if (options.allowMissingState && isProviderStateNotFoundError(error)) return; + throw error; +} + +export interface MaterializedProviderStateFile { + /** Workspace-relative path passed only to the provider process. */ + readonly path: string; + /** Root-relative state path retained by the provider workspace authority. */ + readonly statePath: string; + remove(): Promise; +} + +/** Provider-owned placement capability. Adapters never receive a workspace runtime or its root. */ +export interface ProviderWorkspace { + readonly cwd: string; + resolveExecutable(command: string): Promise; + launch(input: ProviderWorkspaceLaunchInput): Promise; + launchDeferred( + input: ProviderWorkspaceLaunchInput | Promise, + ): ChildProcessWithoutNullStreams; + runProbe(input: { + argv: readonly [string, ...string[]]; + environment?: readonly (ProcessEnvRecord | undefined)[]; + provider: string; + }): Promise<{ stdout: string; stderr: string }>; + readWorkspaceText(path: string): Promise; + writeWorkspaceText(path: string, content: string): Promise; + listState(path: string): Promise; + readStateText(path: string): Promise; + findStateFile(root: string, fileName: string): Promise; + materializeStateFile(input: { + name: string; + content: string | Uint8Array | AsyncIterable; + encoding?: "utf8" | "base64"; + }): Promise; + removeStateFile(path: string): Promise; + allowsHostService(provider: string): boolean; +} + +export function bindProviderWorkspace(input: { + runtime: BoundWorkspaceRuntime; + cwd: string; + policy: ProviderPlacementPolicy; +}): ProviderWorkspace { + const environment = ( + overlays: readonly (ProcessEnvRecord | undefined)[] = [], + ): Record => { + const baseEnv = + input.policy.environment.type === "inherit-sanitized-host" + ? input.policy.environment.hostEnvironment + : {}; + return createProviderEnv({ baseEnv, overlays: [...overlays] }) as Record; + }; + const run = (launch: ProviderWorkspaceLaunchInput): Promise => + input.runtime.run({ + cwd: launch.cwd ?? (input.cwd === "." ? undefined : input.cwd), + argv: launch.argv, + env: environment(launch.environment), + purpose: launch.purpose, + }); + + return { + cwd: input.cwd, + async resolveExecutable(command) { + const resolved = await input.runtime.resolveCommand(command); + if (!resolved) + throw new Error(`Provider command '${command}' was not found in the workspace`); + return resolved; + }, + async launch(launch) { + return wrapWorkspaceProcess(await run(launch), launch.signal); + }, + launchDeferred(launch) { + return wrapDeferredWorkspaceProcess( + Promise.resolve(launch).then(async (resolved) => ({ + process: await run(resolved), + signal: resolved.signal, + })), + ); + }, + async runProbe(probe) { + const process = await run({ + argv: probe.argv, + environment: probe.environment, + purpose: { kind: "provider-probe", provider: probe.provider }, + }); + process.stdin.end(); + const [stdout, stderr, exit] = await Promise.all([ + collect(process.stdout), + collect(process.stderr), + process.exited, + ]); + if (exit.code !== 0 || exit.signal !== null) { + throw new Error(`${probe.provider} probe failed: ${stderr || exit.code || exit.signal}`); + } + return { stdout, stderr }; + }, + async readWorkspaceText(path) { + return collect((await input.runtime.files.read(path)).chunks); + }, + async writeWorkspaceText(path, content) { + const result = await input.runtime.files.write({ path, contents: Buffer.from(content) }); + if (result.status !== "written") { + throw new Error( + result.status === "error" ? result.error : `Workspace file write conflicted: ${path}`, + ); + } + }, + async listState(statePath) { + const relativePath = requireRelativeStatePath(statePath); + const node = await this.resolveExecutable("node"); + const script = [ + 'const f=require("fs/promises"),o=require("os"),p=require("path");', + "(async()=>{const root=await f.realpath(o.homedir()),relative=process.argv[1],target=p.resolve(root,relative),lexical=p.relative(root,target);", + 'if(lexical.startsWith("..")||p.isAbsolute(lexical))throw new Error("Provider state path escapes its root");', + 'let canonical;try{canonical=await f.realpath(target);}catch(error){if(error?.code==="ENOENT"){process.exitCode=44;return;}throw error;}const confined=p.relative(root,canonical);if(confined.startsWith("..")||p.isAbsolute(confined))throw new Error("Provider state symlink escapes its root");', + 'const entries=[];for(const name of await f.readdir(canonical)){const absolute=p.join(canonical,name),info=await f.stat(absolute);if(!info.isFile()&&!info.isDirectory())continue;entries.push({name,path:p.posix.join(relative.split(p.sep).join("/"),name),kind:info.isDirectory()?"directory":"file",size:info.size,modifiedAt:info.mtime.toISOString()});}process.stdout.write(JSON.stringify({path:relative,entries}));})().catch(error=>{process.stderr.write(String(error));process.exitCode=1});', + ].join(""); + const result = await runProviderStateCommand(run, node, script, relativePath); + if (result.exit.code === 44) throw new ProviderStateNotFoundError(relativePath); + if (result.exit.code !== 0 || result.exit.signal !== null) { + throw new Error(`Provider state listing failed: ${result.stderr || result.exit.signal}`); + } + return JSON.parse(result.stdout) as WorkspaceDirectory; + }, + async readStateText(path) { + const statePath = requireRelativeStatePath(path); + const node = await this.resolveExecutable("node"); + const script = [ + 'const f=require("fs/promises"),o=require("os"),p=require("path");', + "(async()=>{const root=await f.realpath(o.homedir()),relative=process.argv[1],target=p.resolve(root,relative),lexical=p.relative(root,target);", + 'if(lexical.startsWith("..")||p.isAbsolute(lexical))throw new Error("Provider state path escapes its root");', + 'let canonical;try{canonical=await f.realpath(target);}catch(error){if(error?.code==="ENOENT"){process.exitCode=44;return;}throw error;}const confined=p.relative(root,canonical);if(confined.startsWith("..")||p.isAbsolute(confined))throw new Error("Provider state symlink escapes its root");process.stdout.write(await f.readFile(canonical));})().catch(error=>{process.stderr.write(String(error));process.exitCode=1});', + ].join(""); + const result = await runProviderStateCommand(run, node, script, statePath); + if (result.exit.code === 44) throw new ProviderStateNotFoundError(statePath); + if (result.exit.code !== 0 || result.exit.signal !== null) { + throw new Error(`Provider state read failed: ${result.stderr || result.exit.signal}`); + } + return result.stdout; + }, + async findStateFile(root, fileName) { + const stateRoot = requireRelativeStatePath(root); + const pending = [stateRoot]; + while (pending.length > 0) { + const directory = pending.shift()!; + let listing; + try { + listing = await this.listState(directory); + } catch (error) { + if (!isProviderStateNotFoundError(error)) throw error; + continue; + } + for (const entry of listing.entries) { + if (entry.kind === "file" && entry.name === fileName) return entry.path; + if (entry.kind === "directory") pending.push(entry.path); + } + } + return null; + }, + async materializeStateFile(stateFile) { + const node = await this.resolveExecutable("node"); + const safeName = stateFile.name.replace(/[^A-Za-z0-9._-]/g, "-"); + const relativePath = `.paseo/provider-state/${randomUUID()}-${safeName}`; + const script = [ + 'const fs=require("fs"),fsp=require("fs/promises"),path=require("path"),stream=require("stream/promises");', + "(async()=>{const root=await fsp.realpath('.'),relative=process.argv[1],target=path.resolve(root,relative),parent=path.dirname(target);", + 'if(path.isAbsolute(relative)||relative.split(/[\\\\/]+/u).includes(".."))throw new Error("Provider state path must be root-relative");', + 'await fsp.mkdir(parent,{recursive:true});const canonicalParent=await fsp.realpath(parent),rel=path.relative(root,canonicalParent);if(rel.startsWith("..")||path.isAbsolute(rel))throw new Error("Provider state path escapes its root");', + 'const handle=await fsp.open(target,"wx",0o600);try{await stream.pipeline(process.stdin,fs.createWriteStream(target,{fd:handle.fd,autoClose:false}));await handle.sync();}catch(error){await fsp.unlink(target).catch(()=>{});throw error;}finally{await handle.close();}})().catch(error=>{process.stderr.write(String(error));process.exitCode=1});', + ].join(""); + const process = await run({ + argv: [node, "-e", script, relativePath], + purpose: { kind: "provider-probe", provider: "provider-state" }, + }); + const stdout = collect(process.stdout); + const stderr = collect(process.stderr); + try { + await writeProcessInput(process, toStateContent(stateFile)); + const [, diagnostics, exit] = await Promise.all([stdout, stderr, process.exited]); + if (exit.code !== 0 || exit.signal !== null) { + throw new Error( + `Provider state materialization failed: ${diagnostics || exit.code || exit.signal}`, + ); + } + return { + path: relativePath, + statePath: relativePath, + remove: () => this.removeStateFile(relativePath), + }; + } catch (error) { + await terminateWorkspaceProcess(process, "SIGKILL").catch(() => undefined); + throw error; + } + }, + async removeStateFile(path) { + const statePath = requireRelativeStatePath(path); + const node = await this.resolveExecutable("node"); + const script = [ + 'const f=require("fs/promises"),p=require("path");', + "(async()=>{const root=await f.realpath('.'),relative=process.argv[1],target=p.resolve(root,relative),lexical=p.relative(root,target);", + 'if(lexical.startsWith("..")||p.isAbsolute(lexical))throw new Error("Provider state path escapes its root");', + 'let canonical;try{canonical=await f.realpath(target);}catch(error){if(error?.code==="ENOENT")return;throw error;}const resolved=p.relative(root,canonical);if(resolved.startsWith("..")||p.isAbsolute(resolved))throw new Error("Provider state symlink escapes its root");const info=await f.lstat(target);if(!info.isFile()&&!info.isSymbolicLink())throw new Error("Provider state removal requires a file");await f.unlink(target);})().catch(error=>{process.stderr.write(String(error));process.exitCode=1});', + ].join(""); + const child = await run({ + argv: [node, "-e", script, statePath], + purpose: { kind: "provider-probe", provider: "provider-state" }, + }); + child.stdin.end(); + const [, stderr, exit] = await Promise.all([ + collect(child.stdout), + collect(child.stderr), + child.exited, + ]); + if (exit.code !== 0 || exit.signal !== null) { + throw new Error(`Provider state removal failed: ${stderr || exit.code || exit.signal}`); + } + }, + allowsHostService(provider) { + return input.policy.sharedHostProviders.has(provider); + }, + }; +} + +export function providerWorkspaceFromCatalogOptions(options: { + scope: "global" | "workspace"; + workspaceId?: string; + workspace?: ProviderWorkspace; +}): ProviderWorkspace | undefined { + if (options.scope !== "workspace") return undefined; + if (options.workspaceId && !options.workspace) { + throw new Error(`workspace runtime capability is unavailable: ${options.workspaceId}`); + } + return options.workspace; +} + +/** Merge explicit provider overlays. Runtime-specific inheritance is applied by the bound capability. */ +export function providerWorkspaceEnvironment( + overlays: readonly (ProcessEnvRecord | undefined)[], +): ProcessEnvRecord { + return Object.assign({}, ...overlays.filter((value): value is ProcessEnvRecord => !!value)); +} + +export function resolveWorkspaceCommand( + workspace: ProviderWorkspace, + command: string, +): Promise { + return workspace.resolveExecutable(command); +} + +export function spawnWorkspaceProviderProcess(input: { + workspace: ProviderWorkspace; + argv: readonly [string, ...string[]]; + env?: ProcessEnvRecord; + purpose: WorkspaceProcessPurpose; +}): Promise { + return input.workspace.launch({ + argv: input.argv, + environment: [input.env], + purpose: input.purpose, + }); +} + +export function runWorkspaceProviderCommand(input: { + workspace: ProviderWorkspace; + argv: readonly [string, ...string[]]; + env?: ProcessEnvRecord; + provider: string; +}): Promise<{ stdout: string; stderr: string }> { + return input.workspace.runProbe({ + argv: input.argv, + environment: [input.env], + provider: input.provider, + }); +} + +function wrapWorkspaceProcess( + process: WorkspaceProcess, + signal?: AbortSignal, +): ChildProcessWithoutNullStreams { + const child = createChildShell(process.stdin, process.stdout, process.stderr, (killSignal) => + process.kill(killSignal), + ); + const unbindAbort = bindAbortSignal(process, child, signal); + forwardExit(process, child, undefined, unbindAbort); + return child; +} + +function wrapDeferredWorkspaceProcess( + processPromise: Promise<{ process: WorkspaceProcess; signal?: AbortSignal }>, +): ChildProcessWithoutNullStreams { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + let process: WorkspaceProcess | null = null; + let pendingSignal: NodeJS.Signals | undefined; + const child = createChildShell(stdin, stdout, stderr, (signal) => { + pendingSignal = signal; + process?.kill(signal); + }); + void processPromise.then( + ({ process: launched, signal }) => { + process = launched; + stdin.pipe(launched.stdin); + launched.stdout.pipe(stdout); + launched.stderr.pipe(stderr); + if (pendingSignal) launched.kill(pendingSignal); + const unbindAbort = bindAbortSignal(launched, child, signal); + child.emit("spawn"); + forwardExit( + launched, + child, + () => { + stdout.end(); + stderr.end(); + }, + unbindAbort, + ); + return undefined; + }, + (error) => { + stdin.destroy(error instanceof Error ? error : new Error(String(error))); + stdout.end(); + stderr.end(); + child.emit("error", error); + return undefined; + }, + ); + return child; +} + +function createChildShell( + stdin: NodeJS.WritableStream, + stdout: NodeJS.ReadableStream, + stderr: NodeJS.ReadableStream, + kill: (signal?: NodeJS.Signals) => void, +): ChildProcessWithoutNullStreams { + const child = new EventEmitter() as ChildProcessWithoutNullStreams; + Object.assign(child, { + stdin, + stdout, + stderr, + pid: undefined, + exitCode: null, + signalCode: null, + killed: false, + kill(signal?: NodeJS.Signals) { + (child as unknown as { killed: boolean }).killed = true; + kill(signal); + return true; + }, + }); + return child; +} + +function forwardExit( + process: WorkspaceProcess, + child: ChildProcessWithoutNullStreams, + beforeEmit?: () => void, + beforeExit?: () => void, +): void { + void process.exited.then( + ({ code, signal }) => { + const mutableChild = child as unknown as { + exitCode: number | null; + signalCode: NodeJS.Signals | null; + }; + mutableChild.exitCode = code; + mutableChild.signalCode = signal; + beforeEmit?.(); + beforeExit?.(); + child.emit("exit", code, signal); + child.emit("close", code, signal); + return undefined; + }, + (error) => child.emit("error", error), + ); +} + +function bindAbortSignal( + process: WorkspaceProcess, + child: ChildProcessWithoutNullStreams, + signal: AbortSignal | undefined, +): () => void { + if (!signal) return () => undefined; + let handled = false; + const onAbort = () => { + if (handled) return; + handled = true; + (child as unknown as { killed: boolean }).killed = true; + void terminateWorkspaceProcess(process, "SIGTERM").catch(() => undefined); + const error = new Error("The operation was aborted") as Error & { code: string }; + error.name = "AbortError"; + error.code = "ABORT_ERR"; + child.emit("error", error); + }; + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) queueMicrotask(onAbort); + return () => signal.removeEventListener("abort", onAbort); +} + +async function terminateWorkspaceProcess( + process: WorkspaceProcess, + initialSignal: NodeJS.Signals, +): Promise { + process.kill(initialSignal); + if (await exitsWithin(process, 1_000)) return; + if (initialSignal !== "SIGKILL") process.kill("SIGKILL"); + if (!(await exitsWithin(process, 1_000))) { + throw new Error("Workspace provider process did not terminate after SIGKILL"); + } +} + +async function exitsWithin(process: WorkspaceProcess, timeoutMs: number): Promise { + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + process.exited.then(() => true), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), timeoutMs); + timeout.unref(); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +async function runProviderStateCommand( + launch: (input: ProviderWorkspaceLaunchInput) => Promise, + node: string, + script: string, + relativePath: string, +): Promise<{ + stdout: string; + stderr: string; + exit: { code: number | null; signal: NodeJS.Signals | null }; +}> { + const process = await launch({ + argv: [node, "-e", script, relativePath], + purpose: { kind: "provider-probe", provider: "provider-state" }, + }); + process.stdin.end(); + const [stdout, stderr, exit] = await Promise.all([ + collect(process.stdout), + collect(process.stderr), + process.exited, + ]); + return { stdout, stderr, exit }; +} + +function requireRelativeStatePath(statePath: string): string { + if ( + !statePath || + nodePath.posix.isAbsolute(statePath) || + nodePath.win32.isAbsolute(statePath) || + statePath.includes("\\") || + statePath.split("/").includes("..") + ) { + throw new Error(`Provider state path must be root-relative: ${statePath}`); + } + return nodePath.posix.normalize(statePath); +} + +function toStateContent(input: { + content: string | Uint8Array | AsyncIterable; + encoding?: "utf8" | "base64"; +}): Uint8Array | AsyncIterable { + if (typeof input.content !== "string") return input.content; + return Buffer.from(input.content, input.encoding ?? "utf8"); +} + +async function writeProcessInput( + process: WorkspaceProcess, + contents: Uint8Array | AsyncIterable, +): Promise { + if (contents instanceof Uint8Array) { + process.stdin.end(contents); + return; + } + for await (const chunk of contents) { + if (!process.stdin.write(chunk)) await onceDrain(process.stdin); + } + process.stdin.end(); +} + +function onceDrain(stream: NodeJS.WritableStream): Promise { + return new Promise((resolve, reject) => { + stream.once("drain", resolve); + stream.once("error", reject); + }); +} + +async function collect(stream: NodeJS.ReadableStream | AsyncIterable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/packages/server/src/server/agent/providers/workspace/workspace.posix.test.ts b/packages/server/src/server/agent/providers/workspace/workspace.posix.test.ts new file mode 100644 index 0000000000..8366d0a136 --- /dev/null +++ b/packages/server/src/server/agent/providers/workspace/workspace.posix.test.ts @@ -0,0 +1,253 @@ +import { once } from "node:events"; +import { access, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, test } from "vitest"; + +import type { BoundWorkspaceRuntime } from "../../../workspace-runtime/index.js"; +import { createWorkspaceRuntimeService } from "../../../workspace-runtime/index.js"; +import { bindProviderWorkspace, resolveProviderPlacementPolicy } from "./index.js"; + +const posixDescribe = describe.runIf(process.platform !== "win32"); + +posixDescribe("provider workspace placement capability", () => { + test("derives provider environment and host services from capabilities, not runtime ids", () => { + expect( + resolveProviderPlacementPolicy({ + capability: { + environment: "isolated", + sharedHostProviders: new Set(["fixture-host-service"]), + }, + hostEnvironment: { HOST_ONLY: "secret" }, + }), + ).toEqual({ + environment: { type: "isolated" }, + sharedHostProviders: new Set(["fixture-host-service"]), + }); + }); + + test("streams large binary state without putting content in argv and removes it safely", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-provider-state-")); + const cwd = path.join(root, "workspace"); + await import("node:fs/promises").then((fs) => fs.mkdir(cwd)); + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => runtimeIds.set(workspaceId, runtimeId), + ...lifecycleRecords(runtimeIds), + }); + await service.create({ + workspaceId: "provider-state", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: cwd } }, + placement: { kind: "existing" }, + }); + const launches: Array = []; + const bound = await service.bind("provider-state"); + const recordingRuntime: BoundWorkspaceRuntime = { + provider: { environment: "isolated", sharedHostProviders: new Set() }, + ...bound, + resolveCommand(command) { + return command === "node" + ? Promise.resolve(process.execPath) + : bound.resolveCommand(command); + }, + run(input) { + launches.push(input.argv); + return bound.run(input); + }, + }; + const workspace = bindProviderWorkspace({ + runtime: recordingRuntime, + cwd: ".", + policy: { + environment: { type: "inherit-sanitized-host", hostEnvironment: process.env }, + sharedHostProviders: new Set(), + }, + }); + const contents = Buffer.alloc(192 * 1024, 0xa5); + + try { + const stateFile = await workspace.materializeStateFile({ + name: "large-image.bin", + content: contents, + }); + expect(await readFile(path.join(cwd, stateFile.path))).toEqual(contents); + expect(launches.flat().join(" ")).not.toContain(contents.toString("base64")); + + await stateFile.remove(); + await expect(access(path.join(cwd, stateFile.path))).rejects.toMatchObject({ + code: "ENOENT", + }); + } finally { + await service.destroy("provider-state"); + await rm(root, { recursive: true, force: true }); + } + }); + + test("rejects absolute, traversal, and escaping-symlink state removal", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-provider-remove-")); + const cwd = path.join(root, "workspace"); + await import("node:fs/promises").then((fs) => fs.mkdir(cwd)); + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => runtimeIds.set(workspaceId, runtimeId), + ...lifecycleRecords(runtimeIds), + }); + await service.create({ + workspaceId: "provider-remove", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: cwd } }, + placement: { kind: "existing" }, + }); + const workspace = bindProviderWorkspace({ + runtime: { + ...(await service.bind("provider-remove")), + resolveCommand: async (command) => + command === "node" + ? process.execPath + : (await service.bind("provider-remove")).resolveCommand(command), + }, + cwd: ".", + policy: { + environment: { type: "inherit-sanitized-host", hostEnvironment: process.env }, + sharedHostProviders: new Set(), + }, + }); + const outside = path.join(root, "outside.txt"); + await writeFile(outside, "outside"); + const stateDirectory = path.join(cwd, ".paseo", "provider-state"); + await import("node:fs/promises").then((fs) => fs.mkdir(stateDirectory, { recursive: true })); + const escapeLink = path.join(stateDirectory, "escape-link"); + await symlink(outside, escapeLink); + const relativeLink = path.relative(cwd, escapeLink); + + try { + await expect(workspace.removeStateFile(outside)).rejects.toThrow(/relative|outside/i); + await expect(workspace.removeStateFile("../outside.txt")).rejects.toThrow( + /relative|outside/i, + ); + await expect(workspace.removeStateFile(relativeLink)).rejects.toThrow(/outside|symlink/i); + await expect(readFile(outside, "utf8")).resolves.toBe("outside"); + } finally { + await rm(stateDirectory, { recursive: true, force: true }); + await service.destroy("provider-remove"); + await rm(root, { recursive: true, force: true }); + } + }); + + test("aborting a deferred selected launch kills the real process and settles its exit", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-provider-abort-")); + const cwd = path.join(root, "workspace"); + await import("node:fs/promises").then((fs) => fs.mkdir(cwd)); + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => runtimeIds.set(workspaceId, runtimeId), + ...lifecycleRecords(runtimeIds), + }); + await service.create({ + workspaceId: "provider-abort", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: cwd } }, + placement: { kind: "existing" }, + }); + const workspace = bindProviderWorkspace({ + runtime: { + ...(await service.bind("provider-abort")), + resolveCommand: async (command) => + command === "node" + ? process.execPath + : (await service.bind("provider-abort")).resolveCommand(command), + }, + cwd: ".", + policy: { + environment: { type: "inherit-sanitized-host", hostEnvironment: process.env }, + sharedHostProviders: new Set(), + }, + }); + const controller = new AbortController(); + const child = workspace.launchDeferred( + Promise.resolve({ + argv: [ + process.execPath, + "-e", + "process.stdout.write(`${process.pid}\\n`);setInterval(()=>{},1000)", + ], + purpose: { kind: "agent", agentId: "abort-agent", provider: "claude" }, + signal: controller.signal, + }), + ); + child.on("error", () => undefined); + + try { + const [pidChunk] = (await once(child.stdout, "data")) as [Buffer]; + const pid = Number(pidChunk.toString().trim()); + controller.abort(); + const [code, signal] = (await once(child, "close")) as [number | null, NodeJS.Signals | null]; + expect({ code, signal }).toEqual({ code: null, signal: "SIGTERM" }); + expect(() => process.kill(pid, 0)).toThrow(); + } finally { + child.kill("SIGKILL"); + await service.destroy("provider-abort"); + await rm(root, { recursive: true, force: true }); + } + }); + + test("distinguishes missing provider state from a paused runtime", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-provider-state-error-")); + const cwd = path.join(root, "workspace"); + await import("node:fs/promises").then((fs) => fs.mkdir(cwd)); + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => runtimeIds.set(workspaceId, runtimeId), + ...lifecycleRecords(runtimeIds), + }); + await service.create({ + workspaceId: "provider-state-error", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: cwd } }, + placement: { kind: "existing" }, + }); + const workspace = bindProviderWorkspace({ + runtime: await service.bind("provider-state-error"), + cwd: ".", + policy: { + environment: { type: "inherit-sanitized-host", hostEnvironment: process.env }, + sharedHostProviders: new Set(), + }, + }); + + try { + await expect( + workspace.findStateFile( + `.paseo/provider-state/missing-${process.pid}-${Date.now()}`, + "missing.jsonl", + ), + ).resolves.toBeNull(); + await service.pause("provider-state-error"); + await expect( + workspace.findStateFile(".claude/projects", "selected-runtime.jsonl"), + ).rejects.toThrow(/runtime is paused/i); + } finally { + await service.destroy("provider-state-error"); + await rm(root, { recursive: true, force: true }); + } + }); +}); + +function lifecycleRecords(runtimeIds: Map) { + return { + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId: string) => { + runtimeIds.delete(workspaceId); + }, + }; +} diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index fcc314ac11..cebb4301da 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -108,6 +108,7 @@ export interface PaseoToolHostDependencies { >; findWorkspaceIdForCwd?: ArchiveDependencies["findWorkspaceIdForCwd"]; listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"]; + listWorkspaceRecords?: ArchiveDependencies["listWorkspaceRecords"]; archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"]; emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; workspaceRegistry?: Pick; @@ -1384,6 +1385,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase { requestId: "mcp:archive_workspace", scope: { kind: "workspace", workspaceId: workspace.workspaceId }, + releaseBacking: true, }, ); return { @@ -3200,6 +3202,7 @@ function archiveWorktreeDependencies( agentStorage: context.agentStorage, findWorkspaceIdForCwd: options.findWorkspaceIdForCwd, listActiveWorkspaces: options.listActiveWorkspaces, + listWorkspaceRecords: options.listWorkspaceRecords, archiveWorkspaceRecord: options.archiveWorkspaceRecord, emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, markWorkspaceArchiving: options.markWorkspaceArchiving, diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts index 5e8cf6c617..2f3c73b469 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts @@ -11,9 +11,15 @@ import { type ArchiveIfSafeDependencies, type AutoArchiveArchiveOptions, } from "./archive-if-safe.js"; -import type { ArchiveResult, ActiveWorkspaceRef } from "../workspace-archive-service.js"; +import { + archiveByScope, + type ArchiveResult, + type ActiveWorkspaceRef, + killTerminalsForWorkspace, +} from "../workspace-archive-service.js"; import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js"; import { createWorktree, type WorktreeConfig } from "../../utils/worktree.js"; +import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; import type { ForgeService } from "../../../services/forge-service.js"; import type { StoredAgentRecord } from "../agent/agent-storage.js"; @@ -114,7 +120,7 @@ function createHarness(overrides?: { clearWorkspaceArchiving: vi.fn(), emitWorkspaceUpdatesForWorkspaceIds: vi.fn(), }; - const archiveByScope = vi.fn( + const archiveByScopeMock = vi.fn( overrides?.archiveByScope ?? (async () => ({ @@ -126,7 +132,7 @@ function createHarness(overrides?: { const resolveWorkspaceIdAtPath = vi.fn( overrides?.resolveWorkspaceIdAtPath ?? (async () => "ws-auto-archive"), ) as unknown as ArchiveIfSafeDependencies["resolveWorkspaceIdAtPath"]; - const isPaseoOwnedWorktreeCwd = vi.fn( + const isPaseoOwnedWorktreeCwdMock = vi.fn( overrides?.isPaseoOwnedWorktreeCwd ?? (async () => ({ allowed: true, @@ -136,9 +142,9 @@ function createHarness(overrides?: { })), ) as unknown as ArchiveIfSafeDependencies["isPaseoOwnedWorktreeCwd"]; const deps: ArchiveIfSafeDependencies = { - archiveByScope, + archiveByScope: archiveByScopeMock, resolveWorkspaceIdAtPath, - isPaseoOwnedWorktreeCwd, + isPaseoOwnedWorktreeCwd: isPaseoOwnedWorktreeCwdMock, killTerminalsForWorkspace: vi.fn(), }; const log = createLogger(); @@ -337,6 +343,13 @@ function createRealOutcomeHarness(input: { options, log: logger, inFlight: new Set(), + deps: { + archiveByScope, + resolveWorkspaceIdAtPath: async () => + active.find((workspace) => workspace.kind === "worktree")?.workspaceId ?? null, + isPaseoOwnedWorktreeCwd, + killTerminalsForWorkspace, + } satisfies ArchiveIfSafeDependencies, }; } @@ -492,6 +505,7 @@ describe("archiveIfSafe", () => { { scope: { kind: "workspace", workspaceId: "ws-auto-archive" }, requestId: "auto-archive-on-merge", + releaseBacking: true, }, ); expect(harness.log.info).toHaveBeenCalledWith( @@ -586,6 +600,7 @@ describe("archiveIfSafe", () => { inFlight: harness.inFlight, options: harness.options, log: harness.log, + deps: harness.deps, }); expect(archivedWorkspaceIds.has(workspaceA)).toBe(true); @@ -614,6 +629,7 @@ describe("archiveIfSafe", () => { inFlight: harness.inFlight, options: harness.options, log: harness.log, + deps: harness.deps, }); expect(archivedWorkspaceIds.has(workspaceA)).toBe(true); diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts index 02c44bb9ba..6a08c562da 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts @@ -16,7 +16,7 @@ import type { import type { ForgeService } from "../../services/forge-service.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; -import type { WorkspaceArchiveContext } from "../workspace-registry.js"; +import type { PersistedWorkspaceRecord, WorkspaceArchiveContext } from "../workspace-registry.js"; export interface AutoArchiveArchiveOptions { paseoHome: string; @@ -29,6 +29,7 @@ export interface AutoArchiveArchiveOptions { terminalManager: TerminalManager; findWorkspaceIdForCwd: (cwd: string) => Promise; listActiveWorkspaces: () => Promise; + listWorkspaceRecords: () => Promise; getAutoArchivedChangeRequestUrl: (workspaceId: string) => Promise; archiveWorkspaceRecord: (workspaceId: string, context?: WorkspaceArchiveContext) => Promise; markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; @@ -129,6 +130,7 @@ export async function archiveIfSafe(input: { agentStorage: options.agentStorage, findWorkspaceIdForCwd: options.findWorkspaceIdForCwd, listActiveWorkspaces: options.listActiveWorkspaces, + listWorkspaceRecords: options.listWorkspaceRecords, archiveWorkspaceRecord: (workspaceIdToArchive) => options.archiveWorkspaceRecord(workspaceIdToArchive, { autoArchivedChangeRequestUrl: pullRequest.url, @@ -149,6 +151,7 @@ export async function archiveIfSafe(input: { { scope: { kind: "workspace", workspaceId }, requestId: "auto-archive-on-merge", + releaseBacking: true, }, ); log.info({ cwd }, "Auto-archived worktree after PR merge"); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 028a371c77..6283f25b39 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1,5 +1,7 @@ import express from "express"; import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; import { constants, existsSync, unlinkSync } from "fs"; import { open } from "fs/promises"; import { randomUUID } from "node:crypto"; @@ -137,13 +139,25 @@ import { } from "./agent/tools/paseo-tools.js"; import type { PaseoToolRuntimeContext } from "./agent/tools/types.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; +import { + bindProviderWorkspace, + resolveProviderPlacementPolicy, +} from "./agent/providers/workspace/index.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry, + resolveSelectedWorkspaceRuntimeId, type WorkspaceArchiveContext, } from "./workspace-registry.js"; +import { + createWorkspaceRuntimeService, + type WorkspaceRuntimeConfig, + type WorkspaceRuntimeRecordStore, + type WorkspaceRuntimeService, +} from "./workspace-runtime/index.js"; +import { createProviderProbeService, type ProviderProbeService } from "./provider-probe/index.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { ScheduleService } from "./schedule/service.js"; import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.js"; @@ -151,6 +165,7 @@ import { resolveConfigFromPersisted, type CliConfigOverrides } from "./config.js import { BrowserToolsBroker } from "./browser-tools/broker.js"; import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; +import { createWorkspaceGitDirectory } from "./workspace-git-directory.js"; import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js"; import { archiveByScope, @@ -185,7 +200,10 @@ import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-regist import { ScriptHealthMonitor } from "./script-health-monitor.js"; import { createScriptStatusEmitter } from "./script-status-projection.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; -import { createWorkspaceScriptsService } from "./session/workspace-scripts/workspace-scripts-service.js"; +import { + createWorkspaceScriptsService, + readWorkspacePaseoConfig, +} from "./session/workspace-scripts/workspace-scripts-service.js"; import { spawnWorkspaceScript } from "./worktree-bootstrap.js"; import { createManagedProcessRegistry, @@ -389,6 +407,8 @@ export interface PaseoDaemonConfig { daemonVersion?: string; desktopManaged?: boolean; worktreesRoot?: string; + workspaceRuntimes?: Readonly>; + workspaceRuntimeCommandResolutionBase?: string; corsAllowedOrigins: string[]; allowedHosts?: HostnamesConfig; hostnames?: HostnamesConfig; @@ -631,8 +651,30 @@ export async function createPaseoDaemon( }); let boundListenTarget: ListenTarget | null = null; let workspaceRegistry: FileBackedWorkspaceRegistry | null = null; + let workspaceRuntime: WorkspaceRuntimeService | null = null; + let providerProbe: ProviderProbeService | null = null; const terminalManager = createConfiguredTerminalManager({ getTerminalActivityUrl: () => createTerminalActivityUrl(boundListenTarget), + launchPty: async (input) => { + const workspace = await workspaceRegistry?.get(input.workspaceId); + if (!workspace) throw new Error(`Workspace not found: ${input.workspaceId}`); + if (!resolveSelectedWorkspaceRuntimeId(workspace)) return null; + if (!workspaceRuntime) throw new Error("Workspace runtime is not available"); + const relativeCwd = path.relative(workspace.cwd, input.cwd); + if (relativeCwd.startsWith("..") || path.isAbsolute(relativeCwd)) { + throw new Error(`Terminal cwd escapes workspace: ${input.cwd}`); + } + return workspaceRuntime.openTerminal({ + workspaceId: input.workspaceId, + cwd: relativeCwd || undefined, + argv: input.argv, + env: input.env, + purpose: { kind: "terminal", terminalId: input.terminalId }, + rows: input.rows, + cols: input.cols, + term: input.term, + }); + }, }); applyTerminalAgentHookSetting({ store: daemonConfigStore, logger }); @@ -665,8 +707,14 @@ export async function createPaseoDaemon( serviceProxy, runtimeStore: scriptRuntimeStore, daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), - resolveWorkspaceDirectory: async (workspaceId) => - (await workspaceRegistry?.get(workspaceId))?.cwd ?? null, + resolveWorkspaceProjection: async (workspaceId) => { + const workspace = await workspaceRegistry?.get(workspaceId); + if (!workspace) return null; + return { + workspaceDirectory: workspace.cwd, + paseoConfig: await readWorkspacePaseoConfig({ workspace, workspaceRuntime, logger }), + }; + }, logger, serviceProxyPublicBaseUrl, }), @@ -791,6 +839,27 @@ export async function createPaseoDaemon( let fileHandle: Awaited> | null = null; try { + if (entry.open) { + const opened = await entry.open(); + res.setHeader("Content-Type", entry.mimeType); + res.setHeader( + "Content-Disposition", + `attachment; filename="${entry.fileName.replace(/["\r\n]/g, "_")}"`, + ); + res.setHeader("Content-Length", opened.size.toString()); + const stream = Readable.from(opened.chunks); + try { + await pipeline(stream, res); + } catch (err) { + logger.error({ err }, "Failed to stream runtime download"); + if (!res.headersSent) res.status(500).json({ error: "Failed to read file" }); + else res.destroy(err instanceof Error ? err : new Error(String(err))); + } finally { + await opened.chunks.cancel(); + } + return; + } + if (!entry.absolutePath) throw new Error("Download source is unavailable"); fileHandle = await open(entry.absolutePath, DOWNLOAD_OPEN_FLAGS); const fileStats = await fileHandle.stat(); if (!fileStats.isFile()) { @@ -849,15 +918,174 @@ export async function createPaseoDaemon( path.join(config.paseoHome, "projects", "workspaces.json"), logger, ); + const providerWorkspaceBindings = new Map>(); + const bindWorkspaceProviderCapability = async (workspaceId: string, _runtimeId: string) => { + if (!workspaceRuntime) throw new Error("Workspace runtime is not available"); + const cached = providerWorkspaceBindings.get(workspaceId); + if (cached) return cached; + const runtime = await workspaceRuntime.bind(workspaceId); + const workspace = bindProviderWorkspace({ + runtime, + cwd: ".", + policy: resolveProviderPlacementPolicy({ + capability: runtime.provider, + hostEnvironment: process.env, + }), + }); + providerWorkspaceBindings.set(workspaceId, workspace); + return workspace; + }; + providerProbe = createProviderProbeService({ + filePath: path.join(config.paseoHome, "projects", "provider-probes.json"), + logger, + projects: projectRegistry, + runtime: { + create: (input) => { + if (!workspaceRuntime) throw new Error("Workspace runtime is not available"); + return workspaceRuntime.create(input); + }, + inspect: (workspaceId) => { + if (!workspaceRuntime) throw new Error("Workspace runtime is not available"); + return workspaceRuntime.inspect(workspaceId); + }, + resume: (workspaceId) => { + if (!workspaceRuntime) throw new Error("Workspace runtime is not available"); + return workspaceRuntime.resume(workspaceId); + }, + pause: (workspaceId) => { + if (!workspaceRuntime) throw new Error("Workspace runtime is not available"); + return workspaceRuntime.pause(workspaceId); + }, + destroy: (workspaceId) => { + if (!workspaceRuntime) throw new Error("Workspace runtime is not available"); + return workspaceRuntime.destroy(workspaceId); + }, + listRuntimes: () => { + if (!workspaceRuntime) throw new Error("Workspace runtime is not available"); + return workspaceRuntime.listRuntimes(); + }, + }, + runtimeConfiguration: config.workspaceRuntimes, + bindWorkspaceProviderCapability, + }); + const workspaceRecords: WorkspaceRuntimeRecordStore = { + resolveRuntimeId: async (workspaceId) => + (await providerProbe?.records.resolveRuntimeId(workspaceId)) ?? + resolveRegistryRuntimeId(workspaceId), + persistRuntimeId: async (workspaceId, runtimeId, placement) => { + if (await providerProbe?.records.resolveRuntimeId(workspaceId)) { + await providerProbe.records.persistRuntimeId(workspaceId, runtimeId, placement); + return; + } + await persistRegistryRuntimeId(workspaceId, runtimeId, placement); + }, + archiveWorkspaceRecord: (workspaceId) => + routeRuntimeRecord(workspaceId, "archiveWorkspaceRecord"), + restoreWorkspaceRecord: (workspaceId) => + routeRuntimeRecord(workspaceId, "restoreWorkspaceRecord"), + beginWorkspaceDeletion: (workspaceId) => + routeRuntimeRecord(workspaceId, "beginWorkspaceDeletion"), + removeWorkspaceRecord: (workspaceId) => + routeRuntimeRecord(workspaceId, "removeWorkspaceRecord"), + listRuntimeRecords: async () => [ + ...((await providerProbe?.records.listRuntimeRecords?.()) ?? []), + ...((await workspaceRegistry?.list()) ?? []).flatMap((workspace) => + workspace.runtime + ? [ + { + workspaceId: workspace.workspaceId, + runtimeId: workspace.runtime.runtimeId, + archived: workspace.archivedAt !== null, + deleting: workspace.deletionRequestedAt !== null, + }, + ] + : [], + ), + ], + }; + workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome: config.paseoHome, + worktreesRoot: config.worktreesRoot, + externalRuntimes: config.workspaceRuntimes, + commandResolutionBase: config.workspaceRuntimeCommandResolutionBase, + ...workspaceRecords, + }); + const detachProviderProbeInvalidation = projectRegistry.subscribeToMutations(async (mutation) => { + if (mutation.kind === "archive" || mutation.kind === "remove") { + await providerProbe?.invalidateProject(mutation.projectId); + } + }); + + async function resolveRegistryRuntimeId(workspaceId: string): Promise { + const workspace = await workspaceRegistry?.get(workspaceId); + return workspace ? resolveSelectedWorkspaceRuntimeId(workspace) : null; + } + + async function persistRegistryRuntimeId( + workspaceId: string, + runtimeId: string, + placement: { cwd: string; hostVisiblePath?: string }, + ): Promise { + const updated = await workspaceRegistry?.update(workspaceId, (workspace) => ({ + ...workspace, + cwd: placement.cwd, + hostVisiblePath: placement.hostVisiblePath ?? null, + runtime: { runtimeId }, + })); + if (!updated) throw new Error(`Workspace not found: ${workspaceId}`); + } + + async function routeRuntimeRecord( + workspaceId: string, + operation: + | "archiveWorkspaceRecord" + | "restoreWorkspaceRecord" + | "beginWorkspaceDeletion" + | "removeWorkspaceRecord", + ): Promise { + const probeRecords = providerProbe?.records; + if (probeRecords && (await probeRecords.resolveRuntimeId(workspaceId))) { + await probeRecords[operation]?.(workspaceId); + if (operation === "removeWorkspaceRecord") { + providerWorkspaceBindings.delete(workspaceId); + } + return; + } + if (operation === "archiveWorkspaceRecord") { + await workspaceRegistry?.archive(workspaceId, new Date().toISOString()); + return; + } + if (operation === "restoreWorkspaceRecord") { + const restoredAt = new Date().toISOString(); + const updated = await workspaceRegistry?.update(workspaceId, (workspace) => ({ + ...workspace, + archivedAt: null, + updatedAt: restoredAt, + })); + if (!updated) throw new Error(`Workspace not found: ${workspaceId}`); + return; + } + if (operation === "beginWorkspaceDeletion") { + await workspaceRegistry?.requestDeletion(workspaceId, new Date().toISOString()); + return; + } + await workspaceRegistry?.remove(workspaceId); + providerWorkspaceBindings.delete(workspaceId); + } const github = createGitHubService(); const workspaceGitService = new WorkspaceGitServiceImpl({ logger, paseoHome: config.paseoHome, worktreesRoot: config.worktreesRoot, + workspaceRuntime, deps: { forgeOverrides: { github }, }, }); + const workspaceGitDirectory = createWorkspaceGitDirectory({ + workspaceRegistry, + workspaceGitService, + }); const workspaceProvisioning = createWorkspaceProvisioningService({ serverId, projectRegistry, @@ -866,6 +1094,15 @@ export async function createPaseoDaemon( logger, }); const providerSnapshotLogger = logger.child({ module: "provider-snapshot-manager" }); + const resolveProviderWorkspace = async (workspaceId: string) => { + const probeWorkspace = await providerProbe?.resolveProviderWorkspace(workspaceId); + if (probeWorkspace) return probeWorkspace; + const workspace = await workspaceRegistry?.get(workspaceId); + if (!workspace) return undefined; + const runtimeId = resolveSelectedWorkspaceRuntimeId(workspace); + if (!runtimeId) return null; + return bindWorkspaceProviderCapability(workspaceId, runtimeId); + }; const providerSnapshotManager = new ProviderSnapshotManager({ logger: providerSnapshotLogger, refreshTimeoutMs: config.providerCatalogRefreshTimeoutMs, @@ -875,17 +1112,18 @@ export async function createPaseoDaemon( managedProcesses, isDev: config.isDev === true, extraClients: config.agentClients, + resolveProviderWorkspace, }); daemonConfigStore.onFieldChange("catalogRefreshTimeoutMs", (value) => { providerSnapshotManager.setRefreshTimeoutMs(typeof value === "number" ? value : undefined); }); daemonConfigStore.onFieldChange("git.maxProcessesPerSecond", () => { const git = daemonConfigStore.get().git; - if (git) configureGitProcessPolicy(git); + if (git) configureGitProcessPolicy(resolveGitProcessPolicy({ env: {}, persisted: git })); }); daemonConfigStore.onFieldChange("git.maxProcessConcurrency", () => { const git = daemonConfigStore.get().git; - if (git) configureGitProcessPolicy(git); + if (git) configureGitProcessPolicy(resolveGitProcessPolicy({ env: {}, persisted: git })); }); const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState(); const agentManager = new AgentManager({ @@ -897,6 +1135,7 @@ export async function createPaseoDaemon( workspaceGitService.onWorkspaceStateMayHaveChanged(cwd); }, mcpAuthToken: agentMcpAuthToken, + resolveProviderWorkspace, logger, }); @@ -907,6 +1146,19 @@ export async function createPaseoDaemon( ); await agentStorage.initialize(); logger.info({ elapsed: elapsed() }, "Agent storage initialized"); + await Promise.all([projectRegistry.initialize(), workspaceRegistry.initialize()]); + try { + await providerProbe.reconcile(); + logger.info({ elapsed: elapsed() }, "Provider probes reconciled"); + } catch (error) { + logger.warn({ err: error }, "Provider probe reconciliation failed"); + } + try { + await workspaceRuntime.reconcile(); + logger.info({ elapsed: elapsed() }, "Workspace runtimes reconciled"); + } catch (error) { + logger.warn({ err: error }, "Workspace runtime reconciliation failed"); + } await bootstrapWorkspaceRegistries({ serverId, paseoHome: config.paseoHome, @@ -948,8 +1200,23 @@ export async function createPaseoDaemon( }); const archiveWorkspaceRecordExternal = async ( workspaceId: string, - context?: WorkspaceArchiveContext, + context?: WorkspaceArchiveContext & { releaseBacking?: boolean }, ) => { + const workspace = await workspaceRegistry.get(workspaceId); + if (workspace?.runtime) { + if (!workspaceRuntime) throw new Error(`Workspace runtime is not available: ${workspaceId}`); + await workspaceRuntime.archive(workspaceId, { + releaseBacking: context?.releaseBacking, + }); + if (context?.autoArchivedChangeRequestUrl) { + await workspaceRegistry.update(workspaceId, (archived) => ({ + ...archived, + autoArchivedChangeRequestUrl: context.autoArchivedChangeRequestUrl ?? null, + })); + } + teardownArchivedWorkspaceRuntime(workspaceId); + return; + } const existingWorkspace = await archivePersistedWorkspaceRecord({ workspaceId, workspaceRegistry, @@ -991,8 +1258,10 @@ export async function createPaseoDaemon( worktreeRoot: workspace.worktreeRoot, isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree, mainRepoRoot: workspace.mainRepoRoot, + runtimeId: workspace.runtime?.runtimeId ?? null, })); }; + const listWorkspaceRecordsExternal = () => workspaceRegistry.list(); const markWorkspaceArchivingExternal = (workspaceIds: Iterable, archivingAt: string) => { const workspaceIdList = Array.from(workspaceIds); for (const session of wsServer?.listTrustedSessions() ?? []) { @@ -1057,6 +1326,7 @@ export async function createPaseoDaemon( logger, findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, listActiveWorkspaces: listActiveWorkspacesExternal, + listWorkspaceRecords: listWorkspaceRecordsExternal, getAutoArchivedChangeRequestUrl: async (workspaceId) => (await workspaceRegistry.get(workspaceId))?.autoArchivedChangeRequestUrl ?? null, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, @@ -1083,6 +1353,8 @@ export async function createPaseoDaemon( : {}), workspaceGitService, workspaceProvisioning, + workspaceRuntime, + workspaceRegistry, }); }, warmWorkspaceGitData: async (workspace) => { @@ -1110,6 +1382,7 @@ export async function createPaseoDaemon( getDaemonTcpHost: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null), serviceProxyPublicBaseUrl, onScriptsChanged: null, + bindWorkspaceRuntime: (workspaceId) => workspaceRuntime.bind(workspaceId), }, input, serviceOptions, @@ -1140,6 +1413,7 @@ export async function createPaseoDaemon( agentStorage, findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, listActiveWorkspaces: listActiveWorkspacesExternal, + listWorkspaceRecords: listWorkspaceRecordsExternal, getWorkspace: (workspaceIdToGet) => workspaceRegistry.get(workspaceIdToGet), archiveWorkspaceRecord: archiveWorkspaceRecordExternal, emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, @@ -1164,7 +1438,9 @@ export async function createPaseoDaemon( archiveAgentCommand({ agentManager, agentStorage, logger }, agentId), findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, listActiveWorkspaces: listActiveWorkspacesExternal, + listWorkspaceRecords: listWorkspaceRecordsExternal, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, + destroyWorkspace: (workspaceId) => workspaceRuntime.destroy(workspaceId), emit: emitExternalSessionMessage, emitAgentRemove: async () => undefined, emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, @@ -1239,6 +1515,7 @@ export async function createPaseoDaemon( agentStorage, findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, listActiveWorkspaces: listActiveWorkspacesExternal, + listWorkspaceRecords: listWorkspaceRecordsExternal, getWorkspace: (workspaceIdToGet) => workspaceRegistry.get(workspaceIdToGet), archiveWorkspaceRecord: archiveWorkspaceRecordExternal, emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, @@ -1258,6 +1535,7 @@ export async function createPaseoDaemon( { scope: { kind: "workspace", workspaceId }, requestId: "schedule-run-finish", + releaseBacking: true, }, ); }; @@ -1305,6 +1583,7 @@ export async function createPaseoDaemon( workspaceGitService, findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, listActiveWorkspaces: listActiveWorkspacesExternal, + listWorkspaceRecords: listWorkspaceRecordsExternal, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, workspaceRegistry, @@ -1324,7 +1603,8 @@ export async function createPaseoDaemon( terminalManager, workspaceRegistry, projectRegistry, - workspaceGitService, + workspaceGitDirectory, + workspaceRuntime, getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), getDaemonTcpHost: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null), serviceProxyPublicBaseUrl, @@ -1630,6 +1910,8 @@ export async function createPaseoDaemon( browserToolsBroker, hubRelationships, workspaceSetupRuntime, + workspaceRuntime, + providerProbe, pluginRuntime, ); relayRuntime = createRelayRuntime({ @@ -1688,6 +1970,7 @@ export async function createPaseoDaemon( await pluginRuntime.stopAllPlugins(); await hubRelationships.stop(); workspaceReconciliation.dispose(); + detachProviderProbeInvalidation(); scriptHealthMonitor.stop(); // Freeze both ingress and registration before taking the agent closure snapshot. wsServer?.prepareForShutdown(); @@ -1697,13 +1980,17 @@ export async function createPaseoDaemon( detachAgentStoragePersistence(); await agentStorage.flush().catch(() => undefined); await providerSnapshotManager.shutdown(); - terminalManager.killAll(); + await providerProbe?.close().catch((error) => { + logger.warn({ err: error }, "Failed to pause provider probes during shutdown"); + }); + await terminalManager.killAll(); speechService.stop(); await scheduleService.stop().catch(() => undefined); await relayRuntime?.stop().catch(() => undefined); if (wsServer) { await wsServer.close(); } + await workspaceRuntime.close(); await serviceProxy.stopStandalone(); // Force-drop remaining sockets so httpServer.close() resolves promptly. // We've already closed wsServer (which sent ws-layer close frames) and diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts index 1d3f2e598d..fec50f33f6 100644 --- a/packages/server/src/server/checkout-diff-manager.test.ts +++ b/packages/server/src/server/checkout-diff-manager.test.ts @@ -13,6 +13,7 @@ vi.mock("./checkout-git-utils.js", () => ({ import type pino from "pino"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; +import { bindWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; interface Deferred { promise: Promise; @@ -64,6 +65,7 @@ function createPendingManager() { unsubscribeCalls: number; resolve(): void; }> = []; + let boundLegacy: ReturnType | null = null; const workspaceGitService = { getCheckoutDiff: async () => ({ diff: "", structured: [] }), getSnapshot: async () => createWorkspaceSnapshot(), @@ -87,6 +89,9 @@ function createPendingManager() { watches.push(watch); return pending.promise; }, + bindLegacy(cwd: string) { + return (boundLegacy ??= bindWorkspaceGitService(this as unknown as WorkspaceGitService, cwd)); + }, }; const logger = { child: () => logger, warn: () => {} }; const manager = new CheckoutDiffManager({ @@ -123,6 +128,7 @@ describe("CheckoutDiffManager", () => { }; }); + let boundLegacy: ReturnType | null = null; const workspaceGitService = { subscribe: vi.fn(), peekSnapshot: vi.fn(), @@ -139,6 +145,12 @@ describe("CheckoutDiffManager", () => { scheduleRefreshForCwd: vi.fn(), requestWorkingTreeWatch: mockRequestWorkingTreeWatch, dispose: vi.fn(), + bindLegacy(cwd: string) { + return (boundLegacy ??= bindWorkspaceGitService( + this as unknown as WorkspaceGitService, + cwd, + )); + }, }; const logger = { @@ -244,7 +256,7 @@ describe("CheckoutDiffManager", () => { expect(watches[0].unsubscribeCalls).toBe(1); }); - test("diffCwd uses repoRoot from the working tree watch result", async () => { + test("the bound workspace owns the diff cwd", async () => { const { manager, workspaceGitService } = createManager({ repoRoot: "/tmp/repo" }); await manager.subscribe( @@ -256,7 +268,7 @@ describe("CheckoutDiffManager", () => { ); expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith( - "/tmp/repo", + "/tmp/repo/packages/server", expect.objectContaining({ mode: "uncommitted", includeStructured: true }), undefined, ); @@ -327,7 +339,7 @@ describe("CheckoutDiffManager", () => { expect(getCheckoutDiff).toHaveBeenNthCalledWith( 1, - "/tmp/repo", + "/tmp/repo/packages/server", expect.objectContaining({ mode: "uncommitted" }), undefined, ); diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 9bf0439aff..337c7f478c 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -1,6 +1,10 @@ import type pino from "pino"; import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js"; -import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; +import type { + WorkspaceGitRuntimeSnapshot, + WorkspaceGitService, + WorkspaceGitWorkspace, +} from "./workspace-git-service.js"; import { expandTilde } from "../utils/path.js"; import { toCheckoutError } from "./checkout-git-utils.js"; @@ -8,6 +12,7 @@ const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150; type CheckoutDiffWorkspace = Pick< WorkspaceGitService, + | "bindLegacy" | "getCheckoutDiff" | "getSnapshot" | "peekSnapshot" @@ -32,7 +37,7 @@ export interface CheckoutDiffMetrics { interface CheckoutDiffWatchTarget { key: string; cwd: string; - diffCwd: string; + workspaceGit: WorkspaceGitWorkspace; compare: CheckoutDiffCompareInput; listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>; workingTreeWatchUnsubscribe: (() => void) | null; @@ -56,6 +61,12 @@ export interface CheckoutDiffSubscriptionRequest { signal?: AbortSignal; } +export interface CheckoutDiffWorkspaceSubscriptionRequest { + workspaceGit: WorkspaceGitWorkspace; + compare: CheckoutDiffCompareInput; + signal?: AbortSignal; +} + export interface CheckoutDiffSubscription { initial: CheckoutDiffSnapshotPayload; unsubscribe: () => void; @@ -64,6 +75,8 @@ export interface CheckoutDiffSubscription { export class CheckoutDiffManager { private readonly workspaceGitService: CheckoutDiffWorkspace; private readonly targets = new Map(); + private readonly workspaceTokens = new WeakMap(); + private nextWorkspaceToken = 1; constructor(options: { logger: pino.Logger; @@ -77,9 +90,22 @@ export class CheckoutDiffManager { params: CheckoutDiffSubscriptionRequest, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, ): Promise { - const cwd = params.cwd; + return this.subscribeWorkspace( + { + workspaceGit: this.workspaceGitService.bindLegacy(params.cwd), + compare: params.compare, + signal: params.signal, + }, + listener, + ); + } + + async subscribeWorkspace( + params: CheckoutDiffWorkspaceSubscriptionRequest, + listener: (snapshot: CheckoutDiffSnapshotPayload) => void, + ): Promise { const compare = this.normalizeCompare(params.compare); - const target = this.ensureTarget(cwd, compare); + const target = this.ensureTarget(params.workspaceGit, compare); target.listeners.add(listener); target.openPromise ??= this.openTarget(target); @@ -100,10 +126,7 @@ export class CheckoutDiffManager { try { await target.openPromise; const initial = - target.latestPayload ?? - (await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, { - diffCwd: target.diffCwd, - })); + target.latestPayload ?? (await this.computeCheckoutDiffSnapshot(target, false)); target.latestPayload = initial; target.latestFingerprint = JSON.stringify(initial); return { initial, unsubscribe }; @@ -114,11 +137,12 @@ export class CheckoutDiffManager { } scheduleRefreshForCwd(cwd: string): void { - const resolvedCwd = expandTilde(cwd); + this.scheduleRefreshForWorkspace(this.workspaceGitService.bindLegacy(expandTilde(cwd))); + } + + scheduleRefreshForWorkspace(workspaceGit: WorkspaceGitWorkspace): void { for (const target of this.targets.values()) { - if (target.cwd !== resolvedCwd && target.diffCwd !== resolvedCwd) { - continue; - } + if (target.workspaceGit !== workspaceGit) continue; this.scheduleTargetRefresh(target); } } @@ -156,9 +180,17 @@ export class CheckoutDiffManager { : { mode: "base", ignoreWhitespace }; } - private buildTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string { + private buildTargetKey( + workspaceGit: WorkspaceGitWorkspace, + compare: CheckoutDiffCompareInput, + ): string { + let workspaceToken = this.workspaceTokens.get(workspaceGit); + if (workspaceToken === undefined) { + workspaceToken = this.nextWorkspaceToken++; + this.workspaceTokens.set(workspaceGit, workspaceToken); + } return JSON.stringify([ - cwd, + workspaceToken, compare.mode, compare.mode === "base" ? (compare.baseRef ?? "") : "", compare.ignoreWhitespace === true, @@ -245,23 +277,20 @@ export class CheckoutDiffManager { } private async computeCheckoutDiffSnapshot( - cwd: string, - compare: CheckoutDiffCompareInput, - options?: { diffCwd?: string; force?: boolean; reason?: string }, + target: CheckoutDiffWatchTarget, + force: boolean, + reason = "checkout-diff-refresh", ): Promise { - const diffCwd = options?.diffCwd ?? cwd; + const { cwd, compare, workspaceGit } = target; try { - const diffResult = await this.workspaceGitService.getCheckoutDiff( - diffCwd, + const diffResult = await workspaceGit.getCheckoutDiff( { mode: compare.mode, baseRef: compare.baseRef, ignoreWhitespace: compare.ignoreWhitespace, includeStructured: true, }, - options?.force - ? { force: true, reason: options.reason ?? "checkout-diff-refresh" } - : undefined, + force ? { force: true, reason } : undefined, ); if (diffResult.diffTooLarge) { return { @@ -302,11 +331,11 @@ export class CheckoutDiffManager { do { target.refreshQueued = false; target.refreshQueuedForce = false; - const snapshot = await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, { - diffCwd: target.diffCwd, - force: currentForce, - ...(currentForce ? { reason: "working-tree-watch" } : {}), - }); + const snapshot = await this.computeCheckoutDiffSnapshot( + target, + currentForce, + currentForce ? "working-tree-watch" : "checkout-diff-refresh", + ); target.latestPayload = snapshot; const fingerprint = JSON.stringify(snapshot); if (fingerprint !== target.latestFingerprint) { @@ -326,8 +355,12 @@ export class CheckoutDiffManager { } } - private ensureTarget(cwd: string, compare: CheckoutDiffCompareInput): CheckoutDiffWatchTarget { - const targetKey = this.buildTargetKey(cwd, compare); + private ensureTarget( + workspaceGit: WorkspaceGitWorkspace, + compare: CheckoutDiffCompareInput, + ): CheckoutDiffWatchTarget { + const cwd = workspaceGit.cwd; + const targetKey = this.buildTargetKey(workspaceGit, compare); const existing = this.targets.get(targetKey); if (existing) { return existing; @@ -336,7 +369,7 @@ export class CheckoutDiffManager { const target: CheckoutDiffWatchTarget = { key: targetKey, cwd, - diffCwd: cwd, + workspaceGit, compare, listeners: new Set(), workingTreeWatchUnsubscribe: null, @@ -360,16 +393,14 @@ export class CheckoutDiffManager { private async openTarget(target: CheckoutDiffWatchTarget): Promise { if (target.compare.mode === "base") { const snapshot = - this.workspaceGitService.peekSnapshot(target.cwd) ?? - (await this.workspaceGitService.getSnapshot(target.cwd, { includeForge: false })); - target.diffCwd = snapshot.git.repoRoot ?? target.cwd; + target.workspaceGit.peekSnapshot() ?? + (await target.workspaceGit.getSnapshot({ includeForge: false })); if (this.targets.get(target.key) !== target || target.listeners.size === 0) { return; } this.rememberWorkspaceSnapshot(target, snapshot); - const workspaceSubscription = this.workspaceGitService.registerWorkspace( - { cwd: target.cwd }, - (nextSnapshot) => this.rememberWorkspaceSnapshot(target, nextSnapshot), + const workspaceSubscription = target.workspaceGit.register((nextSnapshot) => + this.rememberWorkspaceSnapshot(target, nextSnapshot), ); if (this.targets.get(target.key) !== target || target.listeners.size === 0) { workspaceSubscription.unsubscribe(); @@ -379,11 +410,9 @@ export class CheckoutDiffManager { return; } - const { repoRoot, unsubscribe } = await this.workspaceGitService.requestWorkingTreeWatch( - target.cwd, - () => this.scheduleTargetRefresh(target), + const { unsubscribe } = await target.workspaceGit.requestWorkingTreeWatch(() => + this.scheduleTargetRefresh(target), ); - target.diffCwd = repoRoot ?? target.cwd; if (this.targets.get(target.key) !== target || target.listeners.size === 0) { unsubscribe(); return; diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index 80045a4454..a329503361 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -1,11 +1,12 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { afterEach, describe, expect, test } from "vitest"; import { loadConfig, resolveBundledWebUiDistDir, resolveConfigFromPersisted } from "./config.js"; import { loadPersistedConfig } from "./persisted-config.js"; +import { createWorkspaceRuntimeService } from "./workspace-runtime/index.js"; const roots: string[] = []; @@ -27,6 +28,124 @@ describe("server config", () => { expect(standaloneConfig.desktopManaged).toBe(false); }); + test("loads trusted external workspace runtime registrations", async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-runtime-")); + roots.push(paseoHome); + const configPath = path.join(paseoHome, "config.json"); + await writeFile( + configPath, + JSON.stringify({ + version: 1, + workspaceRuntimes: { + fixture: { + type: "command", + command: ["/trusted/runtime", "--mode", "fixture"], + options: { image: "fixture:test" }, + }, + }, + }), + ); + await chmod(configPath, 0o600); + + expect(loadConfig(paseoHome, { env: {} }).workspaceRuntimes).toEqual({ + fixture: { + type: "command", + command: ["/trusted/runtime", "--mode", "fixture"], + options: { image: "fixture:test" }, + }, + }); + }); + + test("lets persisted registrations override or remove distribution registrations", async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-runtime-merge-")); + roots.push(paseoHome); + const configPath = path.join(paseoHome, "config.json"); + await writeFile( + configPath, + JSON.stringify({ + version: 1, + workspaceRuntimes: { + removed: null, + replaced: { type: "command", label: "User", command: ["user-runtime"] }, + }, + }), + ); + await chmod(configPath, 0o600); + + expect( + loadConfig(paseoHome, { + env: { + PASEO_DISTRIBUTION_WORKSPACE_RUNTIMES: JSON.stringify({ + removed: { type: "command", command: ["bundled-removed"] }, + replaced: { type: "command", command: ["bundled-replaced"] }, + retained: { type: "command", label: "Retained", command: ["bundled-retained"] }, + }), + }, + }).workspaceRuntimes, + ).toEqual({ + replaced: { type: "command", label: "User", command: ["user-runtime"] }, + retained: { type: "command", label: "Retained", command: ["bundled-retained"] }, + }); + }); + + test("composes a configured executable for runtimeId selection and rejects others", async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-runtime-compose-")); + roots.push(paseoHome); + const source = path.join(paseoHome, "source"); + const stateDirectory = path.join(paseoHome, "runtime-state"); + await Promise.all([mkdir(source), mkdir(stateDirectory)]); + const fixtureExecutable = fileURLToPath( + new URL("../../../../runtimes/fixture/src/index.mjs", import.meta.url), + ); + const configPath = path.join(paseoHome, "config.json"); + await writeFile( + configPath, + JSON.stringify({ + version: 1, + workspaceRuntimes: { + fixture: { + type: "command", + command: [process.execPath, fixtureExecutable], + options: { stateDirectory }, + }, + }, + }), + ); + await chmod(configPath, 0o600); + const config = loadConfig(paseoHome, { env: {} }); + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome, + externalRuntimes: config.workspaceRuntimes, + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => { + runtimeIds.set(workspaceId, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, + }); + const createInput = { + workspaceId: "configured-runtime", + runtimeId: "fixture", + project: { id: "fixture", source: { kind: "host-directory" as const, path: source } }, + placement: { kind: "existing" as const }, + }; + + await expect(service.create(createInput)).resolves.toEqual({ + workspaceId: "configured-runtime", + runtimeId: "fixture", + cwd: source, + materializedFreshContent: true, + }); + await expect( + service.create({ ...createInput, workspaceId: "unknown-runtime", runtimeId: "unknown" }), + ).rejects.toThrow("Workspace runtime is not registered: unknown"); + await service.destroy("configured-runtime"); + expect(runtimeIds.has("configured-runtime")).toBe(false); + }); + test("loads the provider catalog refresh timeout", async () => { const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-provider-timeout-")); roots.push(paseoHome); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 0c4bfedcde..d2f491cb60 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -10,6 +10,7 @@ import { loadPersistedConfig, LogFormatSchema, LogLevelSchema, + PersistedConfigSchema, type PersistedConfig, } from "./persisted-config.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js"; @@ -556,6 +557,10 @@ export function resolveConfigFromPersisted( const resolvedOptions = options ?? {}; const env = resolvedOptions.env ?? process.env; const cli = resolvedOptions.cli; + const workspaceRuntimes = resolveWorkspaceRuntimeRegistrations( + env.PASEO_DISTRIBUTION_WORKSPACE_RUNTIMES, + persisted.workspaceRuntimes, + ); const relayEnabledFallback = resolvedOptions.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined; @@ -601,6 +606,8 @@ export function resolveConfigFromPersisted( paseoHome, desktopManaged: env.PASEO_DESKTOP_MANAGED === "1", worktreesRoot: resolveWorktreesRoot(paseoHome, persisted), + workspaceRuntimes, + workspaceRuntimeCommandResolutionBase: env.PASEO_DISTRIBUTION_PACKAGE_ROOT, corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted), hostnames, trustedProxies, @@ -650,6 +657,22 @@ export function resolveConfigFromPersisted( }; } +function resolveWorkspaceRuntimeRegistrations( + distributionJson: string | undefined, + configured: PersistedConfig["workspaceRuntimes"], +): PaseoDaemonConfig["workspaceRuntimes"] { + const distribution = distributionJson + ? PersistedConfigSchema.parse({ workspaceRuntimes: JSON.parse(distributionJson) }) + .workspaceRuntimes + : undefined; + const merged = { ...distribution, ...configured }; + return Object.fromEntries( + Object.entries(merged).filter((entry): entry is [string, NonNullable<(typeof entry)[1]>] => + Boolean(entry[1]), + ), + ); +} + export function loadConfig( paseoHome: string, options?: Omit, diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index aa1498a725..282ce71a92 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -354,6 +354,14 @@ describe("DaemonConfigStore", () => { JSON.stringify( { ...initial, + workspaceRuntimes: { + docker: { + type: "command", + label: "Container Lab", + command: ["runtime-package"], + options: { arbitrary: { retained: true } }, + }, + }, agents: { providers: { gemini: { @@ -396,6 +404,12 @@ describe("DaemonConfigStore", () => { command: ["gemini", "--acp"], enabled: false, }); + expect(persisted.workspaceRuntimes?.docker).toEqual({ + type: "command", + label: "Container Lab", + command: ["runtime-package"], + options: { arbitrary: { retained: true } }, + }); }); test("patch removes provider entries from config.json", () => { 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 new file mode 100644 index 0000000000..5dac8c0498 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts @@ -0,0 +1,1000 @@ +import { execFileSync } from "node:child_process"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { FileVersion } from "@getpaseo/protocol/messages"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { createWorkspaceRuntimeService } from "../workspace-runtime/index.js"; +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, + FileBackedProjectRegistry, + FileBackedWorkspaceRegistry, +} from "../workspace-registry.js"; + +const FIXTURE_PROVIDER = "workspace-runtime-fixture"; +const FIXTURE_MODEL = "fixture-model"; +const fixtureAgentPath = fileURLToPath( + new URL( + "../../../../../runtimes/fixture/test/fixtures/workspace-runtime-acp-agent.mjs", + import.meta.url, + ), +); +const fixtureRuntimePath = fileURLToPath( + new URL("../../../../../runtimes/fixture/src/index.mjs", import.meta.url), +); + +interface CharacterizedWorkspace { + cwd: string; + id: string; + projectId: string; + kind: "local" | "worktree"; +} + +let daemon: TestPaseoDaemon; +let client: DaemonClient; +const cleanupRoots: string[] = []; + +beforeAll(async () => { + daemon = await createTestPaseoDaemon({ + mcpEnabled: false, + providerOverrides: { + [FIXTURE_PROVIDER]: { + extends: "acp", + label: "Workspace Runtime Fixture", + command: [process.execPath, fixtureAgentPath], + models: [{ id: FIXTURE_MODEL, label: "Fixture Model", isDefault: true }], + params: { supportsMcpServers: false }, + enabled: true, + }, + }, + }); + client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.3.0-beta.2", + reconnect: { enabled: false }, + }); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "runtime-characterization-agents" } }); +}); + +afterAll(async () => { + await client?.close().catch(() => undefined); + await daemon?.close(); + for (const root of cleanupRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function createRepository(): string { + const root = mkdtempSync(path.join(tmpdir(), "workspace-runtime-characterization-")); + cleanupRoots.push(root); + const repo = path.join(root, "repo"); + execFileSync("git", ["init", "-b", "main", repo], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { + cwd: repo, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repo, stdio: "pipe" }); + writeFileSync(path.join(repo, "characterized.txt"), "before\n"); + writeFileSync(path.join(repo, "binary.bin"), Buffer.alloc(700_000, 0xa5)); + writeFileSync( + path.join(repo, "paseo.json"), + JSON.stringify({ + worktree: { + setup: [ + `${JSON.stringify(process.execPath)} -e "require('fs').writeFileSync('setup-output.txt', 'setup complete\\n')"`, + ], + }, + scripts: { + characterize: { + command: + "sleep 1; printf workspace-script > workspace-script-output.txt; printf runtime-script-ok", + }, + }, + }), + ); + execFileSync("git", ["add", "."], { cwd: repo, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "characterization fixture"], { + cwd: repo, + stdio: "pipe", + }); + return repo; +} + +function createBarrierRepository(mode: "complete" | "fail" = "complete"): string { + const repo = createRepository(); + writeFileSync( + path.join(repo, "setup-barrier.mjs"), + mode === "complete" + ? `import { existsSync, writeFileSync } from "node:fs"; +process.stdout.write("setup-streamed-before-release\\n"); +writeFileSync("setup-environment.json", JSON.stringify({ + custom: process.env.PASEO_SETUP_CUSTOM_INHERITED, + supervised: process.env.PASEO_SUPERVISED, + electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE, + bashEnv: process.env.BASH_ENV, + home: process.env.HOME, + path: process.env.PATH, + source: process.env.PASEO_SOURCE_CHECKOUT_PATH, + root: process.env.PASEO_ROOT_PATH, + worktree: process.env.PASEO_WORKTREE_PATH, + branch: process.env.PASEO_BRANCH_NAME, + port: process.env.PASEO_WORKTREE_PORT, +})); +const timer = setInterval(() => { + if (!existsSync("release-setup")) return; + clearInterval(timer); + writeFileSync("setup-after-release.txt", "completed\\n"); +}, 10); +` + : `process.stdout.write("setup-failed-after-publication\\n"); process.exit(7);\n`, + ); + writeFileSync( + path.join(repo, "paseo.json"), + JSON.stringify({ + worktree: { setup: [`${JSON.stringify(process.execPath)} setup-barrier.mjs`] }, + }), + ); + execFileSync("git", ["add", "."], { cwd: repo, stdio: "pipe" }); + execFileSync("git", ["commit", "--amend", "--no-edit"], { cwd: repo, stdio: "pipe" }); + return repo; +} + +function waitForRawMessage( + selectedClient: DaemonClient, + predicate: (message: SessionOutboundMessage) => message is T, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error("Timed out waiting for public daemon message")); + }, 15_000); + const unsubscribe = selectedClient.subscribeRawMessages((message) => { + if (!predicate(message)) return; + clearTimeout(timeout); + unsubscribe(); + resolve(message); + }); + }); +} + +async function createCharacterizedWorkspace(kind: "local" | "worktree") { + const repo = createRepository(); + const result = await client.createWorkspace({ + runtimeId: kind, + source: + kind === "local" + ? { kind: "directory", path: repo } + : { + kind: "worktree", + cwd: repo, + action: "branch-off", + branchName: "characterized-worktree", + worktreeSlug: "characterized-worktree", + baseBranch: "main", + }, + }); + const workspace = result.workspace; + if (!workspace?.workspaceDirectory) { + throw new Error(result.error ?? `Failed to create ${kind} workspace`); + } + const projection = await client.fetchWorkspaces(); + expect(projection.entries.map((candidate) => candidate.id)).toContain(workspace.id); + return { + cwd: workspace.workspaceDirectory, + id: workspace.id, + projectId: workspace.projectId, + kind, + } satisfies CharacterizedWorkspace; +} + +async function waitForTerminalOutput(terminalId: string, marker: string): Promise { + return new Promise((resolve, reject) => { + const decoder = new TextDecoder(); + let output = ""; + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timed out waiting for terminal output: ${marker}`)); + }, 15_000); + const unsubscribe = client.onTerminalStreamEvent((event) => { + if (event.terminalId !== terminalId || event.type !== "output") return; + output += decoder.decode(event.data, { stream: true }); + if (!output.includes(marker)) return; + clearTimeout(timeout); + unsubscribe(); + resolve(output); + }); + }); +} + +async function expectFileEditAndWatch(workspace: CharacterizedWorkspace): Promise { + const listing = await client.listDirectory(workspace.cwd, ".", undefined, workspace.id); + expect(listing.entries.map((entry) => entry.name)).toContain("characterized.txt"); + + const initialRead = await client.readFile( + workspace.cwd, + "characterized.txt", + undefined, + workspace.id, + ); + expect(new TextDecoder().decode(initialRead.bytes)).toBe("before\n"); + + let resolveUpdate!: (version: FileVersion) => void; + const updated = new Promise((resolve) => { + resolveUpdate = resolve; + }); + const fileSubscription = await client.subscribeFile( + { cwd: workspace.cwd, path: "characterized.txt", workspaceId: workspace.id }, + resolveUpdate, + ); + expect(fileSubscription.initial).toMatchObject({ status: "ready", size: 7 }); + if (fileSubscription.initial.status !== "ready") { + throw new Error("Expected characterized.txt to be ready"); + } + + const write = await client.writeFile({ + cwd: workspace.cwd, + path: "characterized.txt", + content: "after\n", + expectedModifiedAt: fileSubscription.initial.modifiedAt, + expectedRevision: fileSubscription.initial.revision, + workspaceId: workspace.id, + }); + expect(write).toMatchObject({ status: "written", size: 6 }); + await expect(updated).resolves.toMatchObject({ status: "ready", size: 6 }); + fileSubscription.unsubscribe(); + + const writtenRead = await client.readFile( + workspace.cwd, + "characterized.txt", + undefined, + workspace.id, + ); + expect(new TextDecoder().decode(writtenRead.bytes)).toBe("after\n"); + const binaryRead = await client.readFile(workspace.cwd, "binary.bin", undefined, workspace.id); + expect(binaryRead.bytes).toEqual(new Uint8Array(Buffer.alloc(700_000, 0xa5))); + + const download = await client.requestDownloadToken( + workspace.cwd, + "binary.bin", + undefined, + workspace.id, + ); + expect(download.error).toBeNull(); + const response = await fetch( + `http://127.0.0.1:${daemon.port}/api/files/download?token=${download.token}`, + ); + expect(response.status).toBe(200); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(binaryRead.bytes); +} + +async function expectGitObservation(workspace: CharacterizedWorkspace): Promise { + const workspaceGit = client.bindWorkspaceGit({ workspaceId: workspace.id, cwd: workspace.cwd }); + await expect + .poll(() => workspaceGit.getStatus(), { timeout: 15_000 }) + .toMatchObject({ + isGit: true, + isDirty: true, + }); + const diff = await workspaceGit.getDiff({ mode: "uncommitted" }); + expect(diff.error).toBeNull(); + expect(diff.files).toContainEqual( + expect.objectContaining({ path: "characterized.txt", status: "ok" }), + ); + await expect( + workspaceGit.commit({ message: "characterize runtime Git", addAll: true }), + ).resolves.toMatchObject({ success: true, error: null }); + await expect(workspaceGit.refresh()).resolves.toMatchObject({ success: true, error: null }); + await expect(workspaceGit.getStatus()).resolves.toMatchObject({ isGit: true, isDirty: false }); +} + +async function expectTerminalCommand(workspace: CharacterizedWorkspace): Promise { + let resolveFileUpdate!: (version: FileVersion) => void; + const fileUpdated = new Promise((resolve) => { + resolveFileUpdate = resolve; + }); + const fileSubscription = await client.subscribeFile( + { cwd: workspace.cwd, path: "terminal-edit.txt", workspaceId: workspace.id }, + resolveFileUpdate, + ); + expect(fileSubscription.initial).toMatchObject({ status: "missing" }); + const terminal = await client.createTerminal( + workspace.cwd, + `${workspace.kind} terminal`, + undefined, + { + workspaceId: workspace.id, + }, + ); + const terminalId = terminal.terminal?.id; + if (!terminalId) throw new Error(terminal.error ?? "Failed to create terminal"); + const marker = "runtime-terminal-ok"; + const command = "printf terminal-edit > terminal-edit.txt; printf '%s%s\\n' runtime-terminal- ok"; + expect(command).not.toContain(marker); + try { + const terminalOutput = waitForTerminalOutput(terminalId, marker); + await client.subscribeTerminal(terminalId); + client.sendTerminalInput(terminalId, { type: "input", data: `${command}\r` }); + await expect(terminalOutput).resolves.toContain(marker); + await expect(fileUpdated).resolves.toMatchObject({ status: "ready", size: 13 }); + const edit = await client.readFile(workspace.cwd, "terminal-edit.txt", undefined, workspace.id); + expect(new TextDecoder().decode(edit.bytes)).toBe("terminal-edit"); + } finally { + fileSubscription.unsubscribe(); + await client.killTerminal(terminalId); + } +} + +async function expectWorkspaceScript(workspace: CharacterizedWorkspace): Promise { + const listed = await client.listWorkspaceScripts(workspace.id); + expect(listed.scripts).toContainEqual( + expect.objectContaining({ scriptName: "characterize", lifecycle: "stopped" }), + ); + const started = await client.startWorkspaceScriptWithStatus(workspace.id, "characterize"); + expect(started.error).toBeNull(); + const terminalId = started.script?.terminalId; + if (!terminalId) throw new Error("Workspace script did not expose its terminal"); + await client.subscribeTerminal(terminalId); + await expect(waitForTerminalOutput(terminalId, "runtime-script-ok")).resolves.toContain( + "runtime-script-ok", + ); + await expect + .poll( + () => + readWorkspaceTextFile(workspace.id, workspace.cwd, "workspace-script-output.txt").catch( + () => "missing", + ), + { timeout: 15_000 }, + ) + .toBe("workspace-script"); +} + +async function expectProviderDiscoveryAndAgentExecution( + workspace: CharacterizedWorkspace, +): Promise { + await client.refreshProvidersSnapshot({ + cwd: workspace.cwd, + ...(workspace.kind === "local" ? { workspaceId: workspace.id } : {}), + providers: [FIXTURE_PROVIDER], + }); + const providers = await client.getProvidersSnapshot({ + cwd: workspace.cwd, + ...(workspace.kind === "local" ? { workspaceId: workspace.id } : {}), + }); + expect(providers.entries).toContainEqual( + expect.objectContaining({ + provider: FIXTURE_PROVIDER, + label: "Workspace Runtime Fixture", + status: "ready", + models: [expect.objectContaining({ id: FIXTURE_MODEL })], + }), + ); + + const agent = await client.createAgent({ + provider: FIXTURE_PROVIDER, + model: FIXTURE_MODEL, + cwd: workspace.cwd, + workspaceId: workspace.id, + title: `${workspace.kind} stdio fixture`, + }); + await client.sendMessage(agent.id, `characterize ${workspace.kind}`); + const finished = await client.waitForFinish(agent.id, 30_000); + expect(finished.status).toBe("idle"); + const agentRead = await client.readFile( + workspace.cwd, + "stdio-agent-output.txt", + undefined, + workspace.id, + ); + expect(new TextDecoder().decode(agentRead.bytes)).toBe(`characterize ${workspace.kind}\n`); +} + +async function readWorkspaceTextFile( + workspaceId: string, + cwd: string, + filePath: string, +): Promise { + const file = await client.readFile(cwd, filePath, undefined, workspaceId); + return new TextDecoder().decode(file.bytes); +} + +describe("current workspace runtime journeys", () => { + test("local workspace uses the public daemon/client behavior", async () => { + const workspace = await createCharacterizedWorkspace("local"); + try { + const records = JSON.parse( + readFileSync(path.join(daemon.paseoHome, "projects", "workspaces.json"), "utf8"), + ) as Array<{ workspaceId: string; runtime?: { runtimeId: string } }>; + expect(records.find((record) => record.workspaceId === workspace.id)?.runtime).toEqual({ + runtimeId: "local", + }); + expect(existsSync(path.join(workspace.cwd, "setup-output.txt"))).toBe(false); + await expectFileEditAndWatch(workspace); + await expectGitObservation(workspace); + await expectTerminalCommand(workspace); + await expectWorkspaceScript(workspace); + await expectProviderDiscoveryAndAgentExecution(workspace); + const archive = await client.archiveWorkspace(workspace.id); + expect(archive.error).toBeNull(); + expect(existsSync(workspace.cwd)).toBe(true); + expect(readFileSync(path.join(workspace.cwd, "characterized.txt"), "utf8")).toBe("after\n"); + await client.restoreWorkspace(workspace.id); + const removal = await client.removeProject(workspace.projectId); + expect(removal.removedWorkspaceIds).toContain(workspace.id); + expect(existsSync(workspace.cwd)).toBe(true); + } finally { + await client.removeProject(workspace.projectId).catch(() => undefined); + } + }, 120_000); + + test("owned worktree runs setup and uses the same public daemon/client behavior", async () => { + const workspace = await createCharacterizedWorkspace("worktree"); + try { + const records = JSON.parse( + readFileSync(path.join(daemon.paseoHome, "projects", "workspaces.json"), "utf8"), + ) as Array<{ workspaceId: string; runtime?: { runtimeId: string } }>; + expect(records.find((record) => record.workspaceId === workspace.id)?.runtime).toEqual({ + runtimeId: "worktree", + }); + await expect + .poll(() => client.fetchWorkspaceSetupStatus(workspace.id), { timeout: 30_000 }) + .toMatchObject({ snapshot: { status: "completed", error: null } }); + await expect( + readWorkspaceTextFile(workspace.id, workspace.cwd, "setup-output.txt"), + ).resolves.toBe("setup complete\n"); + + await expectFileEditAndWatch(workspace); + await expectGitObservation(workspace); + await expectTerminalCommand(workspace); + await expectWorkspaceScript(workspace); + await expectProviderDiscoveryAndAgentExecution(workspace); + + let resolveRestoredObservation!: () => void; + const restoredObservation = new Promise((resolve) => { + resolveRestoredObservation = resolve; + }); + const restoredSubscription = await client.subscribeFile( + { cwd: workspace.cwd, path: "characterized.txt", workspaceId: workspace.id }, + (version) => { + if (version.status === "ready") resolveRestoredObservation(); + }, + ); + if (restoredSubscription.initial.status !== "ready") { + throw new Error("Expected characterized.txt before archive"); + } + + const archive = await client.archiveWorkspace(workspace.id); + expect(archive.error).toBeNull(); + expect(existsSync(workspace.cwd)).toBe(true); + expect(readFileSync(path.join(workspace.cwd, "characterized.txt"), "utf8")).toBe("after\n"); + + await client.restoreWorkspace(workspace.id); + const restoredWrite = await client.writeFile({ + cwd: workspace.cwd, + path: "characterized.txt", + content: "restored\n", + expectedModifiedAt: restoredSubscription.initial.modifiedAt, + expectedRevision: restoredSubscription.initial.revision, + workspaceId: workspace.id, + }); + expect(restoredWrite.status).toBe("written"); + await expect(restoredObservation).resolves.toBeUndefined(); + restoredSubscription.unsubscribe(); + expect(readFileSync(path.join(workspace.cwd, "characterized.txt"), "utf8")).toBe( + "restored\n", + ); + + const removal = await client.removeProject(workspace.projectId); + expect(removal.removedWorkspaceIds).toContain(workspace.id); + expect(existsSync(workspace.cwd)).toBe(false); + } finally { + if (existsSync(workspace.cwd)) { + await client.removeProject(workspace.projectId).catch(() => undefined); + } + } + }, 120_000); + + test("owned worktree publishes, streams setup, and runs an agent before setup releases", async () => { + const repo = createBarrierRepository(); + const bashEnvMarker = path.join(repo, "bash-env-was-sourced"); + const bashEnvStartup = path.join(repo, "setup-bash-env.sh"); + writeFileSync(bashEnvStartup, `printf sourced > ${JSON.stringify(bashEnvMarker)}\n`); + const originalCustom = process.env.PASEO_SETUP_CUSTOM_INHERITED; + const originalSupervised = process.env.PASEO_SUPERVISED; + const originalElectronRunAsNode = process.env.ELECTRON_RUN_AS_NODE; + const originalBashEnv = process.env.BASH_ENV; + process.env.PASEO_SETUP_CUSTOM_INHERITED = "custom daemon value with spaces"; + process.env.PASEO_SUPERVISED = "must-be-sanitized"; + process.env.ELECTRON_RUN_AS_NODE = "must-be-sanitized"; + process.env.BASH_ENV = bashEnvStartup; + const messages: SessionOutboundMessage[] = []; + const unsubscribe = client.subscribeRawMessages((message) => messages.push(message)); + let workspace: CharacterizedWorkspace | undefined; + try { + const created = await client.createWorkspace({ + runtimeId: "worktree", + source: { + kind: "worktree", + cwd: repo, + action: "branch-off", + branchName: "causal-worktree", + worktreeSlug: "causal-worktree", + baseBranch: "main", + }, + }); + if (!created.workspace?.workspaceDirectory) { + throw new Error(created.error ?? "Causal worktree creation failed"); + } + workspace = { + id: created.workspace.id, + projectId: created.workspace.projectId, + cwd: created.workspace.workspaceDirectory, + kind: "worktree", + }; + + expect(existsSync(path.join(workspace.cwd, "release-setup"))).toBe(false); + expect( + messages.some( + (message) => + message.type === "workspace.create.response" && + message.payload.workspace?.id === workspace?.id, + ), + ).toBe(true); + expect( + messages.some( + (message) => + message.type === "workspace_update" && + message.payload.kind === "upsert" && + message.payload.workspace.id === workspace?.id, + ), + ).toBe(true); + + const isRunningSetup = ( + message: SessionOutboundMessage, + ): message is Extract => + message.type === "workspace_setup_progress" && + message.payload.workspaceId === workspace?.id && + message.payload.status === "running" && + message.payload.detail.commands.some( + (command) => + command.status === "running" && command.log.includes("setup-streamed-before-release"), + ); + const running = + messages.find(isRunningSetup) ?? + (await waitForRawMessage( + client, + ( + message, + ): message is Extract => + isRunningSetup(message), + )); + expect(running.payload.detail.worktreePath).toBe(workspace.cwd); + expect(running.payload.detail.commands[0]?.cwd).toBe(workspace.cwd); + expect(existsSync(path.join(workspace.cwd, "setup-after-release.txt"))).toBe(false); + const setupEnvironment = JSON.parse( + await readWorkspaceTextFile(workspace.id, workspace.cwd, "setup-environment.json"), + ) as Record; + expect(setupEnvironment).toMatchObject({ + custom: "custom daemon value with spaces", + home: process.env.HOME, + path: process.env.PATH, + source: realpathSync(repo), + root: realpathSync(repo), + worktree: workspace.cwd, + branch: "causal-worktree", + port: expect.stringMatching(/^\d+$/u), + }); + expect(setupEnvironment.supervised).toBeUndefined(); + expect(setupEnvironment.electronRunAsNode).toBeUndefined(); + expect(setupEnvironment.bashEnv).toBeUndefined(); + expect(existsSync(bashEnvMarker)).toBe(false); + + await client.refreshProvidersSnapshot({ + cwd: workspace.cwd, + workspaceId: workspace.id, + providers: [FIXTURE_PROVIDER], + }); + const agent = await client.createAgent({ + provider: FIXTURE_PROVIDER, + model: FIXTURE_MODEL, + cwd: workspace.cwd, + workspaceId: workspace.id, + title: "agent before setup release", + }); + await client.sendMessage(agent.id, "agent-before-release"); + expect((await client.waitForFinish(agent.id, 30_000)).status).toBe("idle"); + await expect( + readWorkspaceTextFile(workspace.id, workspace.cwd, "stdio-agent-output.txt"), + ).resolves.toBe("agent-before-release\n"); + expect(existsSync(path.join(workspace.cwd, "release-setup"))).toBe(false); + + writeFileSync(path.join(workspace.cwd, "release-setup"), "release\n"); + await expect + .poll(() => client.fetchWorkspaceSetupStatus(workspace!.id), { timeout: 15_000 }) + .toMatchObject({ snapshot: { status: "completed", error: null } }); + await expect( + readWorkspaceTextFile(workspace.id, workspace.cwd, "setup-after-release.txt"), + ).resolves.toBe("completed\n"); + } finally { + restoreProcessEnvironment("PASEO_SETUP_CUSTOM_INHERITED", originalCustom); + restoreProcessEnvironment("PASEO_SUPERVISED", originalSupervised); + restoreProcessEnvironment("ELECTRON_RUN_AS_NODE", originalElectronRunAsNode); + restoreProcessEnvironment("BASH_ENV", originalBashEnv); + unsubscribe(); + if (workspace) await client.removeProject(workspace.projectId).catch(() => undefined); + } + }, 120_000); + + test("setup failure after publication leaves the worktree usable", async () => { + const repo = createBarrierRepository("fail"); + const created = await client.createWorkspace({ + runtimeId: "worktree", + source: { + kind: "worktree", + cwd: repo, + action: "branch-off", + branchName: "failed-setup-worktree", + worktreeSlug: "failed-setup-worktree", + baseBranch: "main", + }, + }); + if (!created.workspace?.workspaceDirectory) throw new Error(created.error ?? "create failed"); + const workspace = { + id: created.workspace.id, + projectId: created.workspace.projectId, + cwd: created.workspace.workspaceDirectory, + kind: "worktree" as const, + }; + try { + await expect + .poll(() => client.fetchWorkspaceSetupStatus(workspace.id), { timeout: 15_000 }) + .toMatchObject({ snapshot: { status: "failed" } }); + await expectFileEditAndWatch(workspace); + await expectGitObservation(workspace); + await expectTerminalCommand(workspace); + } finally { + await client.removeProject(workspace.projectId).catch(() => undefined); + } + }, 120_000); +}); + +function restoreProcessEnvironment(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +} + +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], + 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: "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("provider probes match user workspace snapshots for every host-backed runtime", async () => { + const root = mkdtempSync(path.join(tmpdir(), "workspace-runtime-provider-probes-")); + cleanupRoots.push(root); + const stateDirectory = path.join(root, "fixture-state"); + const materializeRoot = path.join(root, "fixture-workspaces"); + mkdirSync(stateDirectory, { recursive: true }); + const probeDaemon = await createTestPaseoDaemon({ + mcpEnabled: false, + providerOverrides: { + claude: { enabled: false }, + codex: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + [FIXTURE_PROVIDER]: { + extends: "acp", + label: "Workspace Runtime Fixture", + command: [process.execPath, fixtureAgentPath], + models: [{ id: FIXTURE_MODEL, label: "Fixture Model", isDefault: true }], + params: { supportsMcpServers: false }, + enabled: true, + }, + }, + workspaceRuntimes: { + fixture: { + type: "command", + label: "Fixture", + command: [process.execPath, fixtureRuntimePath], + options: { stateDirectory, materializeRoot }, + }, + }, + }); + const probeClient = new DaemonClient({ + url: `ws://127.0.0.1:${probeDaemon.port}/ws`, + appVersion: "0.3.1", + reconnect: { enabled: false }, + }); + try { + await probeClient.connect(); + for (const runtimeId of ["local", "worktree", "fixture"] as const) { + const repo = path.join(root, `repo-${runtimeId}`); + 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 }); + copyFileSync(fixtureAgentPath, path.join(repo, "fixture-agent.mjs")); + chmodSync(path.join(repo, "fixture-agent.mjs"), 0o755); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["commit", "-m", "fixture"], { cwd: repo }); + const added = await probeClient.addProject(repo); + if (!added.project) throw new Error(added.error ?? `Failed to add ${runtimeId} project`); + + const ensured = await probeClient.ensureWorkspaceRuntimeProbe({ + projectId: added.project.projectId, + runtimeId, + }); + expect(ensured).toMatchObject({ status: "ready", error: null }); + const probeSnapshot = await probeClient.getProvidersSnapshot({ + workspaceId: ensured.workspaceId!, + }); + const created = await probeClient.createWorkspace({ + source: + runtimeId === "worktree" + ? { + kind: "worktree", + projectId: added.project.projectId, + action: "branch-off", + branchName: `probe-${runtimeId}`, + worktreeSlug: `probe-${runtimeId}`, + baseBranch: "main", + } + : { kind: "directory", path: repo, projectId: added.project.projectId }, + runtimeId, + }); + if (!created.workspace) throw new Error(created.error ?? `Failed to create ${runtimeId}`); + const workspaceSnapshot = await probeClient.getProvidersSnapshot({ + workspaceId: created.workspace.id, + }); + expect(probeSnapshot.entries.map(({ fetchedAt: _fetchedAt, ...entry }) => entry)).toEqual( + workspaceSnapshot.entries.map(({ fetchedAt: _fetchedAt, ...entry }) => entry), + ); + expect(probeSnapshot.entries).toContainEqual( + expect.objectContaining({ provider: FIXTURE_PROVIDER, status: "ready" }), + ); + const listed = await probeClient.fetchWorkspaces({ + filter: { projectId: added.project.projectId }, + }); + expect(listed.entries.map((entry) => entry.id)).toEqual([created.workspace.id]); + await probeClient.removeProject(added.project.projectId); + } + } finally { + await probeClient.close().catch(() => undefined); + await probeDaemon.close(); + } +}, 120_000); + +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); + const repo = path.join(root, "repo"); + 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, "characterized.txt"), "before\n"); + copyFileSync(fixtureAgentPath, path.join(repo, "fixture-agent.mjs")); + chmodSync(path.join(repo, "fixture-agent.mjs"), 0o755); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], { + cwd: repo, + }); + const paseoHomeRoot = path.join(root, "daemon-home"); + const paseoHome = path.join(paseoHomeRoot, ".paseo"); + mkdirSync(paseoHome, { recursive: true }); + const workspaceId = `selected-worktree-${Date.now()}`; + let runtimeSelected = true; + const seedRuntime = createWorkspaceRuntimeService({ + paseoHome, + worktreesRoot: path.join(root, "worktrees"), + resolveRuntimeId: async (id) => (id === workspaceId && runtimeSelected ? "worktree" : null), + persistRuntimeId: async () => { + runtimeSelected = true; + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async () => { + runtimeSelected = false; + }, + }); + await seedRuntime.create({ + workspaceId, + runtimeId: "worktree", + project: { id: "selected-worktree-project", source: { kind: "host-directory", path: repo } }, + placement: { + kind: "branch", + branchName: "selected-worktree", + baseRef: "main", + worktreeSlug: workspaceId, + }, + }); + const runtimeState = JSON.parse( + readFileSync( + path.join( + paseoHome, + "workspace-runtimes", + "worktree", + readdirSync(path.join(paseoHome, "workspace-runtimes", "worktree"))[0]!, + ), + "utf8", + ), + ) as { root: string }; + const seededProjectRegistry = new FileBackedProjectRegistry( + path.join(paseoHome, "projects", "projects.json"), + createTestLogger(), + ); + const seededWorkspaceRegistry = new FileBackedWorkspaceRegistry( + path.join(paseoHome, "projects", "workspaces.json"), + createTestLogger(), + ); + await seededProjectRegistry.initialize(); + await seededWorkspaceRegistry.initialize(); + const now = new Date().toISOString(); + await seededProjectRegistry.upsert( + createPersistedProjectRecord({ + projectId: "selected-worktree-project", + rootPath: repo, + kind: "git", + displayName: "selected-worktree-project", + createdAt: now, + updatedAt: now, + }), + ); + await seededWorkspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId, + projectId: "selected-worktree-project", + cwd: runtimeState.root, + kind: "worktree", + displayName: "selected-worktree", + branch: "selected-worktree", + worktreeRoot: runtimeState.root, + mainRepoRoot: repo, + isPaseoOwnedWorktree: true, + runtime: { runtimeId: "worktree" }, + createdAt: now, + updatedAt: now, + }), + ); + const selectedDaemon = await createTestPaseoDaemon({ + paseoHomeRoot, + cleanup: false, + mcpEnabled: false, + providerOverrides: { + [FIXTURE_PROVIDER]: { + extends: "acp", + label: "Workspace Runtime Fixture", + command: [path.join(runtimeState.root, "fixture-agent.mjs")], + params: { supportsMcpServers: false }, + enabled: true, + }, + }, + }); + const selectedClient = new DaemonClient({ + url: `ws://127.0.0.1:${selectedDaemon.port}/ws`, + appVersion: "0.3.0-beta.2", + reconnect: { enabled: false }, + }); + try { + await selectedClient.connect(); + await selectedClient.fetchAgents({ + subscribe: { subscriptionId: "selected-worktree-agents" }, + }); + const snapshot = await selectedClient.getProvidersSnapshot({ + cwd: runtimeState.root, + workspaceId, + }); + expect(snapshot.entries).toContainEqual( + expect.objectContaining({ + provider: FIXTURE_PROVIDER, + status: "ready", + models: [expect.objectContaining({ id: FIXTURE_MODEL })], + }), + ); + const agent = await selectedClient.createAgent({ + provider: FIXTURE_PROVIDER, + model: FIXTURE_MODEL, + cwd: runtimeState.root, + workspaceId, + title: "selected worktree fixture", + }); + await selectedClient.sendMessage(agent.id, "selected worktree edit"); + await expect(selectedClient.waitForFinish(agent.id, 30_000)).resolves.toMatchObject({ + status: "idle", + }); + const edited = await selectedClient.readFile( + runtimeState.root, + "stdio-agent-output.txt", + undefined, + workspaceId, + ); + expect(new TextDecoder().decode(edited.bytes)).toBe("selected worktree edit\n"); + await expect( + selectedClient.bindWorkspaceGit({ workspaceId, cwd: runtimeState.root }).getStatus(), + ).resolves.toMatchObject({ isGit: true, isDirty: true }); + } finally { + await selectedClient.close().catch(() => undefined); + await selectedDaemon.close(); + await seedRuntime.destroy(workspaceId); + } +}, 120_000); diff --git a/packages/server/src/server/file-download/token-store.ts b/packages/server/src/server/file-download/token-store.ts index 7b82f3fc66..672158072f 100644 --- a/packages/server/src/server/file-download/token-store.ts +++ b/packages/server/src/server/file-download/token-store.ts @@ -3,13 +3,18 @@ import { randomUUID } from "node:crypto"; export interface DownloadTokenEntry { token: string; path: string; - absolutePath: string; + absolutePath?: string; + open?: () => Promise<{ chunks: DownloadContent; size: number }>; fileName: string; mimeType: string; size: number; expiresAt: number; } +export interface DownloadContent extends AsyncIterable { + cancel(): Promise; +} + interface DownloadTokenStoreOptions { ttlMs: number; now?: () => number; diff --git a/packages/server/src/server/file-observer/index.test.ts b/packages/server/src/server/file-observer/index.test.ts index 0427cd823b..7222656b81 100644 --- a/packages/server/src/server/file-observer/index.test.ts +++ b/packages/server/src/server/file-observer/index.test.ts @@ -244,7 +244,7 @@ test("observes a thousand concurrent writes and remains healthy after delete and await Promise.all(removedPaths.map((path) => rm(path))); await expect .poll(() => removedPaths.filter((path) => !observed.get(path)?.has("delete")), { - timeout: 10_000, + timeout: 60_000, }) .toEqual([]); @@ -258,7 +258,7 @@ test("observes a thousand concurrent writes and remains healthy after delete and await writeFile(sentinel, "alive"); await expect.poll(() => observed.has(sentinel)).toBe(true); await subscription.unsubscribe(); -}, 90_000); +}, 150_000); test("unsubscribe cancels reconciliation queued by a write burst", async () => { const root = await createRoot(); diff --git a/packages/server/src/server/hub/daemon-executions.test.ts b/packages/server/src/server/hub/daemon-executions.test.ts index a96dc8cfdd..b3485b9244 100644 --- a/packages/server/src/server/hub/daemon-executions.test.ts +++ b/packages/server/src/server/hub/daemon-executions.test.ts @@ -265,7 +265,7 @@ test("failed Hub creates release their lifecycle subscriptions", async () => { expect(await hub.durableOwnedAgentIds()).toEqual([]); expect(await hub.listedWorktrees()).toHaveLength(1); expect(hub.agentSubscriptionCount()).toBe(subscriptionBaseline); -}); +}, 30_000); test("failed Hub create cleans durable state when provider close rejects", async () => { const hub = await launchRelationship(); diff --git a/packages/server/src/server/hub/execution-session.websocket.test.ts b/packages/server/src/server/hub/execution-session.websocket.test.ts index 6a14cbae2b..e9b0c44fdf 100644 --- a/packages/server/src/server/hub/execution-session.websocket.test.ts +++ b/packages/server/src/server/hub/execution-session.websocket.test.ts @@ -216,7 +216,7 @@ test("Hub archives a running execution's Paseo-created worktree", async () => { expect(worktreeCwd).not.toBe(hub.repoRoot()); expect(duringRun).toEqual({ exists: true, listed: true }); expect(response).toMatchObject({ success: true, error: null, action: "archive" }); - expect(afterArchive).toEqual({ exists: false, listed: false }); + expect(afterArchive).toEqual({ exists: true, listed: true }); expect(workspaceId).toEqual(expect.any(String)); expect(await hub.archivedWorkspaceAt(workspaceId!)).toEqual(expect.any(String)); expect(await hub.ownedAgentArchivedAt(worktreeCreated.payload.agentId!)).toEqual( @@ -282,7 +282,7 @@ test("archiving a second same-slug execution leaves the first worktree intact", expect(hub.pathsReferToSameLocation(reused.payload.agent!.cwd, worktreeCwd)).toBe(false); expect(hub.pathsReferToSameLocation(reused.payload.agent!.cwd, secondWorktreeCwd)).toBe(true); expect(await hub.worktreeState(worktreeCwd)).toEqual({ exists: true, listed: true }); - expect(await hub.worktreeState(secondWorktreeCwd)).toEqual({ exists: false, listed: false }); + expect(await hub.worktreeState(secondWorktreeCwd)).toEqual({ exists: true, listed: true }); expect(await hub.agentRemainsAvailable(original.payload.agentId!)).toBe(true); expect(await hub.ownedAgentArchivedAt(reused.payload.agentId!)).toEqual(expect.any(String)); expect(await hub.ownedWorkspaceArchivedAt(reused.payload.agentId!)).toEqual(expect.any(String)); @@ -309,7 +309,7 @@ test("Hub resolves persisted execution ownership after daemon restart", async () expect(await hub.ownedAgentArchivedAt(created.payload.agentId!)).toEqual(expect.any(String)); expect(workspaceId).toEqual(expect.any(String)); expect(await hub.archivedWorkspaceAt(workspaceId!)).toEqual(expect.any(String)); - expect(await hub.worktreeState(worktreeCwd)).toEqual({ exists: false, listed: false }); + expect(await hub.worktreeState(worktreeCwd)).toEqual({ exists: true, listed: true }); }, 20_000); test("Hub treats missing and foreign executions as already controlled without exposing ownership", async () => { diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index 9589ffb753..5d00ee9fde 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -34,6 +34,7 @@ import type { ProviderCatalog, } from "../../agent/agent-sdk-types.js"; import { createTestAgentClients } from "../../test-utils/fake-agent-client.js"; +import { resolveHostProviderWorkspace } from "../../test-utils/provider-workspace-stub.js"; import { DaemonClient } from "../../test-utils/daemon-client.js"; import { AgentStorage } from "../../agent/agent-storage.js"; import { AgentManager } from "../../agent/agent-manager.js"; @@ -826,7 +827,6 @@ export class HubRelationshipHarness { { provider: "codex", cwd: this.root }, undefined, { - workspaceId: "foreign-workspace", owner: { kind: "daemon", daemonId: "another-daemon", executionId }, }, ); @@ -1082,7 +1082,7 @@ export class HubRelationshipHarness { const agent = await this.daemon!.agentManager.createAgent( { provider: "codex", cwd: this.root }, undefined, - { workspaceId: "local-workspace" }, + {}, ); return agent.id; } @@ -1186,6 +1186,7 @@ export class HubRelationshipHarness { clients: createTestAgentClients(), registry: storage, logger: pino({ level: "silent" }), + resolveProviderWorkspace: resolveHostProviderWorkspace, }); const executions = this.executionsForReconstruction(manager, storage); const replay = await executions.create(this.ownedCreateInput(executionId)); diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index c40f710d38..c9e66e4af1 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -24,6 +24,7 @@ import { createWorktree, getPaseoWorktreesRoot } from "../utils/worktree.js"; import { isPlatform } from "../test-utils/platform.js"; import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../utils/path.js"; import { deriveProjectKey } from "./project-key.js"; +import { createWorkspaceRuntimeService } from "./workspace-runtime/index.js"; const cleanupPaths: string[] = []; @@ -497,7 +498,8 @@ test.skipIf(isPlatform("win32"))( const { repoDir, tempDir } = createGitRepo(); cleanupPaths.push(tempDir); const paseoHome = path.join(tempDir, ".paseo"); - const firstDeps = createDeps(); + const runtimeHome = path.join(tempDir, "runtime-home"); + const firstDeps = createDeps({ runtimeHome }); const first = await createPaseoWorktree( { cwd: repoDir, @@ -512,6 +514,7 @@ test.skipIf(isPlatform("win32"))( events, projects: firstDeps.projects, workspaces: firstDeps.workspaces, + runtimeHome, }); const second = await createPaseoWorktree( @@ -526,6 +529,8 @@ test.skipIf(isPlatform("win32"))( expect(second.created).toBe(true); expect(second.worktree.worktreePath).not.toBe(first.worktree.worktreePath); + // Compatibility cwd is presentation data; repeated requests get distinct + // runtime-owned placement and identity. expect(path.basename(second.worktree.worktreePath)).toBe("reuse-me-1"); expect(events).toContain(`workspace:${second.workspace.workspaceId}`); expect(second.workspace.workspaceId).not.toBe(first.workspace.workspaceId); @@ -1055,6 +1060,7 @@ function createDeps(options?: { events?: string[]; projects?: Map; workspaces?: Map; + runtimeHome?: string; }): TestDeps { const events = options?.events ?? []; const projects = options?.projects ?? new Map(); @@ -1119,6 +1125,29 @@ function createDeps(options?: { workspaceGitService, logger: createTestLogger(), }); + const runtimeHome = + options?.runtimeHome ?? mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-runtime-")); + if (!options?.runtimeHome) cleanupPaths.push(runtimeHome); + const workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome: runtimeHome, + worktreesRoot: path.join(runtimeHome, "worktrees"), + resolveRuntimeId: async (workspaceId) => + workspaces.get(workspaceId)?.runtime?.runtimeId ?? null, + persistRuntimeId: async (workspaceId, runtimeId, placement) => { + const workspace = workspaces.get(workspaceId); + if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`); + workspaces.set(workspaceId, { + ...workspace, + cwd: placement.cwd, + hostVisiblePath: placement.hostVisiblePath ?? null, + runtime: { runtimeId }, + }); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + workspaces.delete(workspaceId); + }, + }); return { github: createGitHubServiceStub(), @@ -1126,6 +1155,8 @@ function createDeps(options?: { workspaces, workspaceGitService, workspaceProvisioning, + workspaceRuntime, + workspaceRegistry, }; } diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index b32123cb37..98c95e951f 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -1,4 +1,3 @@ -import { stat } from "node:fs/promises"; import { resolve } from "node:path"; import type { WorkspaceGitService } from "./workspace-git-service.js"; @@ -6,29 +5,24 @@ import { getRealpathAwareRelativePath } from "../utils/path.js"; import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; import type { WorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js"; import { - createWorktreeCore, type CreateWorktreeCoreDeps, type CreateWorktreeCoreInput, + planWorktreeCore, } from "./worktree-core.js"; -import { - mapWorkspaceRelativeCwdToWorktree, - rollbackCreatedPaseoWorktree, - seedPaseoConfigFile, - validateBranchSlug, - type WorktreeConfig, -} from "../utils/worktree.js"; +import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js"; import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js"; import { markPaseoWorktreeFirstAgentBranchAutoNameAttempted, normalizeBaseRefName, readPaseoWorktreeMetadata, - writePaseoWorktreeFirstAgentBranchAutoNameMetadata, } from "../utils/worktree-metadata.js"; import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.js"; import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js"; import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js"; import type { FirstAgentContext } from "@getpaseo/protocol/messages"; import { runWithGitCommandPriority } from "../utils/run-git-command.js"; +import type { WorkspaceRuntimeService } from "./workspace-runtime/index.js"; +import type { WorkspaceRegistry } from "./workspace-registry.js"; export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { projectId?: string; @@ -58,7 +52,9 @@ export interface AttemptFirstAgentBranchAutoNameResult { export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps { workspaceGitService: WorkspaceGitService; - workspaceProvisioning: Pick; + workspaceProvisioning: Pick; + workspaceRuntime: WorkspaceRuntimeService; + workspaceRegistry: Pick; } export async function createPaseoWorktree( @@ -73,67 +69,72 @@ async function createPaseoWorktreeWithPriority( deps: CreatePaseoWorktreeDeps, ): Promise { const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService); - const createdWorktree = await createWorktreeCore(input, deps); + const plan = await planWorktreeCore(input, deps); + const branchName = resolveIntentBranch(plan.intent); + const workspace = await deps.workspaceProvisioning.reserveRuntimeWorktreeWorkspace({ + sourceCwd: workspaceCwdPlan.inputCwd, + projectId: input.projectId, + repoRoot: plan.repoRoot, + branch: branchName, + baseBranch: resolveIntentBaseBranch(plan.intent), + title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext), + expectsInitialAgent: Boolean(input.firstAgentContext), + }); try { - maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); - const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({ - relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd, - targetWorktreePath: createdWorktree.worktree.worktreePath, + const placement = await deps.workspaceRuntime.create({ + workspaceId: workspace.workspaceId, + runtimeId: "worktree", + project: { id: workspace.projectId, source: { kind: "host-directory", path: plan.repoRoot } }, + placement: { + kind: "resolved-worktree", + source: plan.intent, + worktreeSlug: plan.worktreeSlug, + ...(workspaceCwdPlan.relativeWorkspaceCwd + ? { relativeCwd: workspaceCwdPlan.relativeWorkspaceCwd } + : {}), + }, + markFirstAgentBranchAutoName: plan.intent.kind === "branch-off", + seedPaseoConfigFrom: workspaceCwdPlan.inputCwd, }); - if (!(await isDirectory(workspaceCwd))) { - throw new Error(`Selected project directory is missing from the worktree: ${workspaceCwd}`); + if (!placement.hostVisiblePath) { + throw new Error(`Worktree runtime has no host-visible path: ${workspace.workspaceId}`); } - - if (createdWorktree.created) { - await seedPaseoConfigFile({ - sourceCwd: workspaceCwdPlan.inputCwd, - targetCwd: workspaceCwd, - }); + const worktreeRoot = resolvePublicWorktreeRoot( + placement.hostVisiblePath, + workspaceCwdPlan.relativeWorkspaceCwd, + ); + const persistedWorkspace = await deps.workspaceRegistry.update( + workspace.workspaceId, + (record) => ({ ...record, worktreeRoot }), + ); + if (!persistedWorkspace) { + throw new Error(`Created workspace record is missing: ${workspace.workspaceId}`); } - const workspace = await deps.workspaceProvisioning.createWorkspaceForWorktree({ - sourceCwd: workspaceCwdPlan.inputCwd, - projectId: input.projectId, - repoRoot: createdWorktree.repoRoot, - cwd: workspaceCwd, - worktreeRoot: createdWorktree.worktree.worktreePath, - branch: createdWorktree.worktree.branchName || null, - baseBranch: resolveIntentBaseBranch(createdWorktree.intent), - title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext), - expectsInitialAgent: Boolean(input.firstAgentContext), - }); - - deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath }); + const worktree = { + branchName, + worktreePath: worktreeRoot, + }; + deps.github.invalidate({ cwd: worktree.worktreePath }); return { - worktree: createdWorktree.worktree, - intent: createdWorktree.intent, - workspace, - repoRoot: createdWorktree.repoRoot, - created: createdWorktree.created, + worktree, + intent: plan.intent, + workspace: persistedWorkspace, + repoRoot: plan.repoRoot, + created: placement.materializedFreshContent, }; } catch (error) { - if (!createdWorktree.created) { - throw error; - } - return rollbackCreatedPaseoWorktree( - { - cwd: createdWorktree.repoRoot, - worktreePath: createdWorktree.worktree.worktreePath, - ...(input.runSetup === false ? { teardownCwds: [] } : {}), - paseoHome: input.paseoHome, - worktreesBaseRoot: input.worktreesRoot, - }, - error, - ); + await deps.workspaceRuntime + .destroy(workspace.workspaceId) + .catch(() => deps.workspaceRegistry.remove(workspace.workspaceId)); + throw error; } } -async function isDirectory(targetPath: string): Promise { - try { - return (await stat(targetPath)).isDirectory(); - } catch { - return false; - } +function resolvePublicWorktreeRoot(compatibilityCwd: string, relativeWorkspaceCwd: string): string { + if (!relativeWorkspaceCwd) return compatibilityCwd; + const depth = relativeWorkspaceCwd.split(/[\\/]/u).filter(Boolean).length; + return resolve(compatibilityCwd, ...Array.from({ length: depth }, () => "..")); } async function planWorkspaceCwdForWorktree( @@ -248,19 +249,6 @@ async function findAvailableBranchName(options: { return null; } -function maybeMarkFirstAgentBranchAutoNameEligible(options: { - createdWorktree: Awaited>; -}): void { - const { createdWorktree } = options; - if (!createdWorktree.created || createdWorktree.intent.kind !== "branch-off") { - return; - } - - writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, { - placeholderBranchName: createdWorktree.worktree.branchName, - }); -} - // The base branch is normalized to match worktree.json's baseRefName (origin/ // stripped). checkout-branch worktrees have no distinct base, so they stay null. function resolveIntentBaseBranch(intent: WorktreeCreationIntent): string | null { @@ -275,3 +263,14 @@ function resolveIntentBaseBranch(intent: WorktreeCreationIntent): string | null return null; } } + +function resolveIntentBranch(intent: WorktreeCreationIntent): string { + switch (intent.kind) { + case "branch-off": + case "checkout-branch": + return intent.branchName; + case "checkout-change-request": + case "checkout-github-pr": + return intent.localBranchName ?? intent.headRef; + } +} diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 8ea1c5c25d..76b430bc06 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -137,6 +137,51 @@ describe("PersistedConfigSchema worktrees config", () => { }); }); +describe("PersistedConfigSchema workspace runtime config", () => { + test("accepts generic command registrations and rejects Docker as a public type", () => { + expect( + PersistedConfigSchema.parse({ + workspaceRuntimes: { + fixture: { + type: "command", + label: "Fixture", + command: ["/trusted/runtime", "--fixture"], + options: { arbitrary: { nested: [true, 3, null] } }, + }, + }, + }).workspaceRuntimes, + ).toEqual({ + fixture: { + type: "command", + label: "Fixture", + command: ["/trusted/runtime", "--fixture"], + options: { arbitrary: { nested: [true, 3, null] } }, + }, + }); + expect(() => + PersistedConfigSchema.parse({ + workspaceRuntimes: { invalid: { type: "command", command: [] } }, + }), + ).toThrow(); + expect(() => + PersistedConfigSchema.parse({ + workspaceRuntimes: { + docker: { + type: "docker", + image: "paseo-workspace:test", + }, + }, + }), + ).toThrow(); + }); + + test("uses null as the generic removal marker for a distribution registration", () => { + expect( + PersistedConfigSchema.parse({ workspaceRuntimes: { bundled: null } }).workspaceRuntimes, + ).toEqual({ bundled: null }); + }); +}); + describe("PersistedConfigSchema provider credentials", () => { test("accepts separate OpenAI STT and TTS credentials", () => { const parsed = PersistedConfigSchema.parse({ diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 2012ba093d..30da7939db 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -87,6 +87,23 @@ 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.json()).optional(), + }) + .strict(); + +const WorkspaceRuntimesConfigSchema = z.record( + z.string().min(1), + CommandWorkspaceRuntimeConfigSchema.nullable(), +); + const BcryptHashSchema = z.string().regex(/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/, { message: "Expected a bcrypt hash", }); @@ -312,6 +329,7 @@ export const PersistedConfigSchema = z pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), worktrees: WorktreesConfigSchema.optional(), + workspaceRuntimes: WorkspaceRuntimesConfigSchema.optional(), agents: z .object({ providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(), diff --git a/packages/server/src/server/provider-probe/index.ts b/packages/server/src/server/provider-probe/index.ts new file mode 100644 index 0000000000..43568d6d92 --- /dev/null +++ b/packages/server/src/server/provider-probe/index.ts @@ -0,0 +1,65 @@ +import type pino from "pino"; +import type { PersistedProjectRecord } from "../workspace-registry.js"; + +import type { ProviderWorkspace } from "../agent/providers/workspace/index.js"; +import type { + CreateWorkspaceInput, + WorkspaceRuntimeInspection, + WorkspaceRuntimePlacement, + WorkspaceRuntimeRecordStore, +} from "../workspace-runtime/index.js"; +import { createProbeStore } from "./internal/probe-store.js"; +import { createService } from "./internal/service.js"; + +export interface ProviderProbeService { + ensure(input: { projectId: string; runtimeId: string }): Promise<{ workspaceId: string }>; + resolveProviderWorkspace(workspaceId: string): Promise; + invalidateProject(projectId: string): Promise; + reconcile(): Promise; + close(): Promise; + readonly records: WorkspaceRuntimeRecordStore; +} + +export interface ProviderProbeTimer { + unref?(): void; +} + +export interface ProviderProbeClock { + now(): Date; + setTimeout(callback: () => void | Promise, delayMs: number): ProviderProbeTimer; + clearTimeout(timer: ProviderProbeTimer): void; +} + +export interface ProviderProbeServiceOptions { + filePath: string; + logger: pino.Logger; + projects: { + get( + projectId: string, + ): Promise | null>; + }; + runtime: { + create(input: CreateWorkspaceInput): Promise; + inspect(workspaceId: string): Promise; + pause(workspaceId: string): Promise; + resume(workspaceId: string): Promise; + destroy(workspaceId: string): Promise; + listRuntimes(): readonly { runtimeId: string }[]; + }; + clock?: ProviderProbeClock; + runtimeConfiguration?: Readonly>; + bindWorkspaceProviderCapability( + workspaceId: string, + runtimeId: string, + ): Promise; +} + +export function createProviderProbeService( + options: ProviderProbeServiceOptions, +): ProviderProbeService { + const store = createProbeStore(options.filePath, options.logger); + return createService({ ...options, store }); +} diff --git a/packages/server/src/server/provider-probe/internal/probe-store.ts b/packages/server/src/server/provider-probe/internal/probe-store.ts new file mode 100644 index 0000000000..6796022223 --- /dev/null +++ b/packages/server/src/server/provider-probe/internal/probe-store.ts @@ -0,0 +1,142 @@ +import { promises as fs } from "node:fs"; + +import type pino from "pino"; +import { z } from "zod"; + +import { writeJsonFileAtomic } from "../../atomic-file.js"; +import type { WorkspaceRuntimeRecordStore } from "../../workspace-runtime/index.js"; + +const ProbeRecordSchema = z + .object({ + workspaceId: z.string(), + projectId: z.string(), + runtimeId: z.string(), + fingerprint: z.string(), + status: z.enum(["materializing", "ready", "paused", "destroying"]), + lastUsedAt: z.iso.datetime(), + createdAt: z.iso.datetime(), + }) + .strict(); + +export type ProviderProbeRecord = z.infer; + +export interface ProbeStore { + readonly records: WorkspaceRuntimeRecordStore; + get(workspaceId: string): Promise; + list(): Promise; + put(record: ProviderProbeRecord): Promise; + update( + workspaceId: string, + updater: (record: ProviderProbeRecord) => ProviderProbeRecord, + ): Promise; + remove(workspaceId: string): Promise; +} + +export function createProbeStore(filePath: string, logger: pino.Logger): ProbeStore { + let loaded = false; + let queue = Promise.resolve(); + const cache = new Map(); + const log = logger.child({ module: "provider-probe-store" }); + + async function load(): Promise { + if (loaded) return; + try { + const records = z + .array(ProbeRecordSchema) + .parse(JSON.parse(await fs.readFile(filePath, "utf8"))); + for (const record of records) cache.set(record.workspaceId, record); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + loaded = true; + return; + } + log.error({ err: error, filePath }, "Failed to load provider probes"); + throw error; + } + loaded = true; + } + + async function mutate( + operation: (draft: Map) => TResult, + ): Promise { + await load(); + const previous = queue; + let release!: () => void; + queue = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + const draft = new Map(cache); + const result = operation(draft); + await writeJsonFileAtomic(filePath, [...draft.values()]); + cache.clear(); + for (const [workspaceId, record] of draft) cache.set(workspaceId, record); + return result; + } finally { + release(); + } + } + + const store: ProbeStore = { + records: { + async resolveRuntimeId(workspaceId) { + return (await store.get(workspaceId))?.runtimeId ?? null; + }, + async persistRuntimeId(workspaceId, runtimeId) { + const updated = await store.update(workspaceId, (record) => ({ + ...record, + runtimeId, + status: "ready", + })); + if (!updated) throw new Error(`Provider probe record not found: ${workspaceId}`); + }, + async archiveWorkspaceRecord(workspaceId) { + await store.update(workspaceId, (record) => ({ ...record, status: "paused" })); + }, + async restoreWorkspaceRecord(workspaceId) { + await store.update(workspaceId, (record) => ({ ...record, status: "ready" })); + }, + async beginWorkspaceDeletion(workspaceId) { + await store.update(workspaceId, (record) => ({ ...record, status: "destroying" })); + }, + async removeWorkspaceRecord(workspaceId) { + await store.remove(workspaceId); + }, + async listRuntimeRecords() { + await load(); + return [...cache.values()].map((record) => ({ + workspaceId: record.workspaceId, + runtimeId: record.runtimeId, + archived: record.status === "paused", + deleting: record.status === "destroying", + })); + }, + }, + async get(workspaceId) { + await load(); + return cache.get(workspaceId) ?? null; + }, + async list() { + await load(); + return [...cache.values()]; + }, + async put(record) { + const parsed = ProbeRecordSchema.parse(record); + await mutate((draft) => draft.set(parsed.workspaceId, parsed)); + }, + async update(workspaceId, updater) { + return mutate((draft) => { + const current = draft.get(workspaceId); + if (!current) return null; + const next = ProbeRecordSchema.parse(updater(current)); + draft.set(workspaceId, next); + return next; + }); + }, + async remove(workspaceId) { + await mutate((draft) => draft.delete(workspaceId)); + }, + }; + return store; +} diff --git a/packages/server/src/server/provider-probe/internal/service.ts b/packages/server/src/server/provider-probe/internal/service.ts new file mode 100644 index 0000000000..2e79b8ac28 --- /dev/null +++ b/packages/server/src/server/provider-probe/internal/service.ts @@ -0,0 +1,365 @@ +import { createHash } from "node:crypto"; + +import { runGitCommand } from "../../../utils/run-git-command.js"; +import { projectRuntimeSource } from "../../workspace-registry.js"; +import type { + ProviderProbeClock, + ProviderProbeService, + ProviderProbeServiceOptions, + ProviderProbeTimer, +} from "../index.js"; +import type { ProbeStore, ProviderProbeRecord } from "./probe-store.js"; + +const idlePauseMilliseconds = 30 * 60_000; +const startupDestroyMilliseconds = 14 * 24 * 60 * 60_000; + +interface ServiceOptions extends ProviderProbeServiceOptions { + store: ProbeStore; +} + +const systemClock: ProviderProbeClock = { + now: () => new Date(), + setTimeout(callback, delayMs) { + return setTimeout(() => void callback(), delayMs); + }, + clearTimeout(timer) { + clearTimeout(timer as ReturnType); + }, +}; + +export function createService(options: ServiceOptions): ProviderProbeService { + const clock = options.clock ?? systemClock; + const log = options.logger.child({ module: "provider-probe" }); + const inFlight = new Map< + string, + { projectId: string; promise: Promise<{ workspaceId: string }> } + >(); + const workspaceTails = new Map>(); + const idleTimers = new Map(); + let closed = false; + + return { + records: options.store.records, + ensure(input) { + if (closed) return Promise.reject(new Error("Provider probe service is closed")); + const workspaceId = probeWorkspaceId(input.projectId, input.runtimeId); + const pending = inFlight.get(workspaceId); + if (pending) return pending.promise; + const ensurePromise = sequence(workspaceId, () => materialize(input, workspaceId)).finally( + () => { + if (inFlight.get(workspaceId)?.promise === ensurePromise) inFlight.delete(workspaceId); + }, + ); + inFlight.set(workspaceId, { projectId: input.projectId, promise: ensurePromise }); + return ensurePromise; + }, + async resolveProviderWorkspace(workspaceId) { + const record = await options.store.get(workspaceId); + if (!record || record.status !== "ready") return null; + return options.bindWorkspaceProviderCapability(workspaceId, record.runtimeId); + }, + async invalidateProject(projectId) { + await Promise.allSettled( + [...inFlight.values()] + .filter((pending) => pending.projectId === projectId) + .map((pending) => pending.promise), + ); + const records = (await options.store.list()).filter( + (record) => record.projectId === projectId, + ); + await Promise.all( + records.map((record) => + sequence(record.workspaceId, async () => { + const current = await options.store.get(record.workspaceId); + if (current?.projectId === projectId) await destroy(current); + }), + ), + ); + }, + async reconcile() { + const failures: unknown[] = []; + for (const record of await options.store.list()) { + try { + await sequence(record.workspaceId, () => reconcileRecord(record.workspaceId)); + } catch (error) { + failures.push(error); + } + } + if (failures.length > 0) { + throw new AggregateError(failures, "Provider probe reconciliation failed"); + } + }, + async close() { + if (closed) return; + closed = true; + clearAllTimers(); + await Promise.allSettled([...inFlight.values()].map((pending) => pending.promise)); + const failures: unknown[] = []; + for (const record of await options.store.list()) { + try { + await sequence(record.workspaceId, async () => { + const current = await options.store.get(record.workspaceId); + if (current?.status === "ready") await pause(current); + }); + } catch (error) { + failures.push(error); + } + } + if (failures.length > 0) { + throw new AggregateError(failures, "Provider probe shutdown failed"); + } + }, + }; + + async function materialize( + input: { projectId: string; runtimeId: string }, + workspaceId: string, + ): Promise<{ workspaceId: string }> { + const project = await options.projects.get(input.projectId); + if (!project || project.archivedAt) throw new Error(`Project not found: ${input.projectId}`); + if (!isRuntimeRegistered(input.runtimeId)) { + throw new Error(`Workspace runtime is not registered: ${input.runtimeId}`); + } + const timestamp = clock.now().toISOString(); + const fingerprint = await probeFingerprint({ + projectId: project.projectId, + source: projectRuntimeSource(project), + runtimeId: input.runtimeId, + runtimeConfiguration: options.runtimeConfiguration?.[input.runtimeId], + }); + let existing = await options.store.get(workspaceId); + if ( + existing && + (existing.projectId !== project.projectId || + existing.runtimeId !== input.runtimeId || + existing.fingerprint !== fingerprint || + existing.status === "destroying") + ) { + await destroy(existing); + existing = null; + } + if (existing) { + const inspection = await options.runtime.inspect(workspaceId); + if (inspection.status === "ready") { + await markReady(existing, timestamp); + return { workspaceId }; + } + if (inspection.status === "paused") { + await options.runtime.resume(workspaceId); + await markReady(existing, timestamp); + return { workspaceId }; + } + await destroy(existing); + } + + await options.store.put({ + workspaceId, + projectId: project.projectId, + runtimeId: input.runtimeId, + fingerprint, + status: "materializing", + lastUsedAt: timestamp, + createdAt: timestamp, + }); + try { + await options.runtime.create({ + workspaceId, + runtimeId: input.runtimeId, + project: { + id: project.projectId, + source: projectRuntimeSource(project), + }, + placement: { kind: "existing" }, + purpose: "provider-probe", + }); + const created = await options.store.get(workspaceId); + if (!created) throw new Error(`Provider probe record not found after create: ${workspaceId}`); + await markReady(created, timestamp); + return { workspaceId }; + } catch (error) { + const created = await options.store.get(workspaceId); + if (created) { + try { + await destroy(created); + } catch (cleanupError) { + throw new Error(`Provider probe creation failed before cleanup: ${String(error)}`, { + cause: cleanupError, + }); + } + } + throw error; + } + } + + async function reconcileRecord(workspaceId: string): Promise { + const record = await options.store.get(workspaceId); + if (!record) return; + if (!isRuntimeRegistered(record.runtimeId)) { + clearIdleTimer(workspaceId); + await options.store.remove(workspaceId); + return; + } + if (record.status === "destroying") { + await destroy(record); + return; + } + const project = await options.projects.get(record.projectId); + if (!project || project.archivedAt || isExpired(record)) { + await destroy(record); + return; + } + const fingerprint = await probeFingerprint({ + projectId: project.projectId, + source: projectRuntimeSource(project), + runtimeId: record.runtimeId, + runtimeConfiguration: options.runtimeConfiguration?.[record.runtimeId], + }); + if (fingerprint !== record.fingerprint) { + await destroy(record); + return; + } + const inspection = await options.runtime.inspect(workspaceId); + if (inspection.status === "missing" || inspection.status === "error") { + await destroy(record); + return; + } + if (inspection.status === "ready") { + await pause(record); + return; + } + await options.store.update(workspaceId, (current) => ({ ...current, status: "paused" })); + } + + async function markReady(record: ProviderProbeRecord, timestamp: string): Promise { + await options.store.update(record.workspaceId, (current) => ({ + ...current, + status: "ready", + lastUsedAt: timestamp, + })); + scheduleIdlePause(record.workspaceId, timestamp); + } + + async function pause(record: ProviderProbeRecord): Promise { + clearIdleTimer(record.workspaceId); + const inspection = await options.runtime.inspect(record.workspaceId); + if (inspection.status === "missing" || inspection.status === "error") { + await destroy(record); + return; + } + if (inspection.status === "ready") await options.runtime.pause(record.workspaceId); + await options.store.update(record.workspaceId, (current) => ({ ...current, status: "paused" })); + } + + async function destroy(record: ProviderProbeRecord): Promise { + clearIdleTimer(record.workspaceId); + await options.runtime.destroy(record.workspaceId); + } + + function scheduleIdlePause(workspaceId: string, lastUsedAt: string): void { + clearIdleTimer(workspaceId); + if (closed) return; + const remaining = Math.max( + 0, + idlePauseMilliseconds - (clock.now().getTime() - Date.parse(lastUsedAt)), + ); + const timer = clock.setTimeout(async () => { + idleTimers.delete(workspaceId); + try { + await sequence(workspaceId, async () => { + const record = await options.store.get(workspaceId); + if (!record || record.status !== "ready") return; + const elapsed = clock.now().getTime() - Date.parse(record.lastUsedAt); + if (elapsed < idlePauseMilliseconds) { + scheduleIdlePause(workspaceId, record.lastUsedAt); + return; + } + await pause(record); + }); + } catch (error) { + log.warn({ err: error, workspaceId }, "Failed to pause idle provider probe"); + } + }, remaining); + timer.unref?.(); + idleTimers.set(workspaceId, timer); + } + + function isExpired(record: ProviderProbeRecord): boolean { + return clock.now().getTime() - Date.parse(record.lastUsedAt) >= startupDestroyMilliseconds; + } + + function isRuntimeRegistered(runtimeId: string): boolean { + return options.runtime.listRuntimes().some((runtime) => runtime.runtimeId === runtimeId); + } + + function clearIdleTimer(workspaceId: string): void { + const timer = idleTimers.get(workspaceId); + if (!timer) return; + clock.clearTimeout(timer); + idleTimers.delete(workspaceId); + } + + function clearAllTimers(): void { + for (const timer of idleTimers.values()) clock.clearTimeout(timer); + idleTimers.clear(); + } + + async function sequence(workspaceId: string, operation: () => Promise): Promise { + const previous = workspaceTails.get(workspaceId) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const tail = previous.then(() => gate); + workspaceTails.set(workspaceId, tail); + await previous; + try { + return await operation(); + } finally { + release(); + if (workspaceTails.get(workspaceId) === tail) workspaceTails.delete(workspaceId); + } + } +} + +function probeWorkspaceId(projectId: string, runtimeId: string): string { + return `wksprobe_${hash({ projectId, runtimeId }).slice(0, 32)}`; +} + +async function probeFingerprint(input: { + projectId: string; + source: ReturnType; + runtimeId: string; + runtimeConfiguration: unknown; +}): Promise { + const sourceRevision = + input.source.kind === "host-directory" + ? await resolveSourceRevision(input.source.path) + : input.source.revision || null; + return hash({ ...input, sourceRevision }); +} + +async function resolveSourceRevision(rootPath: string): Promise { + try { + const { stdout } = await runGitCommand(["rev-parse", "HEAD"], { cwd: rootPath }); + const revision = stdout.trim(); + return revision || null; + } catch { + return null; + } +} + +function hash(value: unknown): string { + return createHash("sha256") + .update(JSON.stringify(canonicalize(value))) + .digest("hex"); +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, canonicalize(entry)]), + ); +} diff --git a/packages/server/src/server/provider-probe/provider-probe.posix.test.ts b/packages/server/src/server/provider-probe/provider-probe.posix.test.ts new file mode 100644 index 0000000000..610da84583 --- /dev/null +++ b/packages/server/src/server/provider-probe/provider-probe.posix.test.ts @@ -0,0 +1,533 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import pino from "pino"; +import { afterEach, describe, expect, test } from "vitest"; + +import type { ProviderWorkspace } from "../agent/providers/workspace/index.js"; +import type { + CreateWorkspaceInput, + WorkspaceRuntimeInspection, + WorkspaceRuntimePlacement, +} from "../workspace-runtime/index.js"; +import { createProviderProbeService } from "./index.js"; + +const roots: string[] = []; +const posixDescribe = describe.runIf(process.platform !== "win32"); + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +posixDescribe("provider probe service", () => { + test("converges concurrent ensures on one deterministic persisted runtime create", async () => { + const fixture = await createFixture(); + let releaseCreate!: () => void; + const createBarrier = new Promise((resolve) => { + releaseCreate = resolve; + }); + fixture.runtime.create = async (input) => { + fixture.creates.push(input); + await createBarrier; + fixture.inspection = { status: "ready", cwd: fixture.projectRoot }; + await fixture.service.records.persistRuntimeId(input.workspaceId, input.runtimeId, { + cwd: fixture.projectRoot, + }); + return placement(input, fixture.projectRoot); + }; + + const first = fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" }); + const second = fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" }); + releaseCreate(); + + const [firstResult, secondResult] = await Promise.all([first, second]); + expect(firstResult).toEqual(secondResult); + expect(firstResult.workspaceId).toMatch(/^wksprobe_[a-f0-9]{32}$/u); + expect(fixture.creates).toHaveLength(1); + expect(fixture.creates[0]).toEqual({ + workspaceId: firstResult.workspaceId, + runtimeId: "fixture", + project: { + id: "project-1", + source: { kind: "host-directory", path: fixture.projectRoot }, + }, + placement: { kind: "existing" }, + purpose: "provider-probe", + }); + await expect(fixture.service.records.resolveRuntimeId(firstResult.workspaceId)).resolves.toBe( + "fixture", + ); + }); + + test("materializes a persisted git source without granting authority to rootPath", async () => { + const fixture = await createFixture({ + projectSource: { + kind: "git", + url: "https://example.test/acme/project.git", + revision: "release", + subdirectory: "packages/app", + }, + }); + + await fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" }); + + expect(fixture.creates[0]?.project.source).toEqual({ + kind: "git", + url: "https://example.test/acme/project.git", + revision: "release", + subdirectory: "packages/app", + }); + expect(JSON.stringify(fixture.creates[0]?.project.source)).not.toContain(fixture.projectRoot); + }); + + test("reuses persisted ready identity and resolves only probe provider workspaces", async () => { + const first = await createFixture(); + const ensured = await first.service.ensure({ projectId: "project-1", runtimeId: "fixture" }); + const providerWorkspace = await first.service.resolveProviderWorkspace(ensured.workspaceId); + const reopened = createService(first); + + first.inspection = { status: "ready", cwd: first.projectRoot }; + await expect( + reopened.ensure({ projectId: "project-1", runtimeId: "fixture" }), + ).resolves.toEqual(ensured); + expect(first.creates).toHaveLength(1); + await expect(reopened.resolveProviderWorkspace(ensured.workspaceId)).resolves.toBe( + providerWorkspace, + ); + await expect(reopened.resolveProviderWorkspace("ordinary-workspace")).resolves.toBeNull(); + }); + + test("removes a failed materialization so an explicit retry can create again", async () => { + const fixture = await createFixture(); + fixture.runtime.create = async (input) => { + fixture.creates.push(input); + if (fixture.creates.length === 1) throw new Error("fixture materialization failed"); + fixture.inspection = { status: "ready", cwd: fixture.projectRoot }; + await fixture.service.records.persistRuntimeId(input.workspaceId, input.runtimeId, { + cwd: fixture.projectRoot, + }); + return placement(input, fixture.projectRoot); + }; + + await expect( + fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" }), + ).rejects.toThrow("fixture materialization failed"); + await expect( + fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" }), + ).resolves.toMatchObject({ workspaceId: expect.stringMatching(/^wksprobe_/u) }); + expect(fixture.creates).toHaveLength(2); + }); + + test("recreation replaces a persisted stale fingerprint with the freshly computed value", async () => { + const fixture = await createFixture(); + const ensured = await fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" }); + const recordsPath = path.join(fixture.root, "provider-probes.json"); + const [record] = JSON.parse(await readFile(recordsPath, "utf8")) as Array< + Record + >; + await writeFile( + recordsPath, + `${JSON.stringify([{ ...record, fingerprint: "stale", status: "materializing" }], null, 2)}\n`, + ); + fixture.inspection = { status: "missing" }; + const reopened = createService(fixture); + fixture.service = reopened; + + await reopened.ensure({ projectId: "project-1", runtimeId: "fixture" }); + + const [recreated] = JSON.parse(await readFile(recordsPath, "utf8")) as Array<{ + workspaceId: string; + fingerprint: string; + }>; + expect(recreated.workspaceId).toBe(ensured.workspaceId); + expect(recreated.fingerprint).not.toBe("stale"); + }); + + test("pauses after the idle TTL and resumes on the next ensure without rotating the provider binding", async () => { + const fixture = await createFixture(); + const ensured = await fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" }); + const firstBinding = await fixture.service.resolveProviderWorkspace(ensured.workspaceId); + + await fixture.clock.advanceBy(30 * 60_000); + + expect(fixture.pauses).toEqual([ensured.workspaceId]); + expect(fixture.inspection.status).toBe("paused"); + await expect(fixture.service.resolveProviderWorkspace(ensured.workspaceId)).resolves.toBeNull(); + + await expect( + fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" }), + ).resolves.toEqual(ensured); + expect(fixture.resumes).toEqual([ensured.workspaceId]); + await expect(fixture.service.resolveProviderWorkspace(ensured.workspaceId)).resolves.toBe( + firstBinding, + ); + }); + + test("destroys and recreates when the committed project source changes", async () => { + const fixture = await createFixture({ gitProject: true }); + const first = await fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" }); + const firstBinding = await fixture.service.resolveProviderWorkspace(first.workspaceId); + + await writeFile(path.join(fixture.projectRoot, "source.txt"), "v2\n"); + execFileSync("git", ["add", "source.txt"], { cwd: fixture.projectRoot }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "v2"], { + cwd: fixture.projectRoot, + }); + + await expect( + fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" }), + ).resolves.toEqual(first); + expect(fixture.destroys).toEqual([first.workspaceId]); + expect(fixture.creates).toHaveLength(2); + await expect(fixture.service.resolveProviderWorkspace(first.workspaceId)).resolves.not.toBe( + firstBinding, + ); + }); + + test("destroys and recreates when runtime configuration changes", async () => { + const fixture = await createFixture(); + const first = await fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" }); + const reconfigured = createService(fixture, { + fixture: { type: "command", options: { sentinel: false } }, + }); + fixture.service = reconfigured; + + await expect( + reconfigured.ensure({ projectId: "project-1", runtimeId: "fixture" }), + ).resolves.toEqual(first); + expect(fixture.destroys).toEqual([first.workspaceId]); + expect(fixture.creates).toHaveLength(2); + }); + + test("startup reconcile pauses valid probes and destroys stale, interrupted, and orphaned probes", async () => { + const fixture = await createFixture(); + const valid = await fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" }); + const recordsPath = path.join(fixture.root, "provider-probes.json"); + const [validRecord] = JSON.parse(await readFile(recordsPath, "utf8")) as Array< + Record + >; + const oldTimestamp = "2026-07-01T00:00:00.000Z"; + await writeFile( + recordsPath, + `${JSON.stringify( + [ + validRecord, + { + ...validRecord, + workspaceId: "wksprobe_old", + projectId: "project-1", + lastUsedAt: oldTimestamp, + createdAt: oldTimestamp, + }, + { + ...validRecord, + workspaceId: "wksprobe_interrupted", + status: "destroying", + }, + { + ...validRecord, + workspaceId: "wksprobe_materializing_ready", + status: "materializing", + }, + { + ...validRecord, + workspaceId: "wksprobe_materializing_missing", + status: "materializing", + }, + { + ...validRecord, + workspaceId: "wksprobe_orphan", + projectId: "missing-project", + }, + ], + null, + 2, + )}\n`, + ); + fixture.inspections.set(valid.workspaceId, { status: "ready", cwd: fixture.projectRoot }); + fixture.inspections.set("wksprobe_old", { status: "paused", cwd: fixture.projectRoot }); + fixture.inspections.set("wksprobe_interrupted", { status: "missing" }); + fixture.inspections.set("wksprobe_materializing_ready", { + status: "ready", + cwd: fixture.projectRoot, + }); + fixture.inspections.set("wksprobe_materializing_missing", { status: "missing" }); + fixture.inspections.set("wksprobe_orphan", { status: "ready", cwd: fixture.projectRoot }); + const restarted = createService(fixture); + fixture.service = restarted; + + await restarted.reconcile(); + + expect(fixture.pauses).toContain(valid.workspaceId); + expect(fixture.destroys).toEqual( + expect.arrayContaining([ + "wksprobe_old", + "wksprobe_interrupted", + "wksprobe_materializing_missing", + "wksprobe_orphan", + ]), + ); + const remaining = JSON.parse(await readFile(recordsPath, "utf8")) as Array<{ + workspaceId: string; + status: string; + }>; + expect(remaining).toEqual( + expect.arrayContaining([ + expect.objectContaining({ workspaceId: valid.workspaceId, status: "paused" }), + expect.objectContaining({ + workspaceId: "wksprobe_materializing_ready", + status: "paused", + }), + ]), + ); + expect(remaining).toHaveLength(2); + }); + + test("project invalidation converges with concurrent ensure and removes the runtime record", async () => { + const fixture = await createFixture(); + let releaseCreate!: () => void; + const createBarrier = new Promise((resolve) => { + releaseCreate = resolve; + }); + const originalCreate = fixture.runtime.create; + fixture.runtime.create = async (input) => { + await createBarrier; + return originalCreate(input); + }; + + const ensure = fixture.service.ensure({ projectId: "project-1", runtimeId: "fixture" }); + const invalidations = Promise.all([ + fixture.service.invalidateProject("project-1"), + fixture.service.invalidateProject("project-1"), + ]); + releaseCreate(); + const ensured = await ensure; + await invalidations; + + expect(fixture.destroys).toEqual([ensured.workspaceId]); + await expect(fixture.service.records.resolveRuntimeId(ensured.workspaceId)).resolves.toBeNull(); + }); + + test("rejects invalid persisted lifecycle timestamps at the store boundary", async () => { + const fixture = await createFixture(); + await writeFile( + path.join(fixture.root, "invalid-provider-probes.json"), + `${JSON.stringify([ + { + workspaceId: "wksprobe_invalid", + projectId: "project-1", + runtimeId: "fixture", + fingerprint: "fingerprint", + status: "paused", + lastUsedAt: "yesterday-ish", + createdAt: "2026-08-12T00:00:00.000Z", + }, + ])}\n`, + ); + const invalid = createProviderProbeService({ + filePath: path.join(fixture.root, "invalid-provider-probes.json"), + logger: pino({ enabled: false }), + projects: { get: async () => null }, + runtime: fixture.runtime, + clock: fixture.clock, + bindWorkspaceProviderCapability: async () => ({ cwd: "." }) as ProviderWorkspace, + }); + + await expect(invalid.reconcile()).rejects.toThrow(); + }); +}); + +async function createFixture( + options: { + gitProject?: boolean; + projectSource?: { + kind: "git"; + url: string; + revision?: string; + subdirectory?: string; + }; + } = {}, +) { + const root = await mkdtemp(path.join(tmpdir(), "paseo-provider-probe-")); + roots.push(root); + const projectRoot = path.join(root, "project"); + await mkdir(projectRoot); + if (options.gitProject) { + execFileSync("git", ["init", "-b", "main"], { cwd: projectRoot }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { cwd: projectRoot }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: projectRoot }); + await writeFile(path.join(projectRoot, "source.txt"), "v1\n"); + execFileSync("git", ["add", "source.txt"], { cwd: projectRoot }); + execFileSync("git", ["commit", "-m", "v1"], { cwd: projectRoot }); + } + let inspection: WorkspaceRuntimeInspection = { status: "missing" }; + const inspections = new Map(); + const creates: CreateWorkspaceInput[] = []; + const pauses: string[] = []; + const resumes: string[] = []; + const destroys: string[] = []; + const providerWorkspaces = new Map(); + const clock = new TestClock(Date.parse("2026-08-12T00:00:00.000Z")); + let service: ReturnType; + const runtime = { + create: async (input: CreateWorkspaceInput) => { + creates.push(input); + inspection = { status: "ready", cwd: projectRoot }; + inspections.set(input.workspaceId, inspection); + await service.records.persistRuntimeId(input.workspaceId, input.runtimeId, { + cwd: projectRoot, + }); + return placement(input, projectRoot); + }, + inspect: async (workspaceId: string) => inspections.get(workspaceId) ?? inspection, + pause: async (workspaceId: string) => { + pauses.push(workspaceId); + inspection = { status: "paused", cwd: projectRoot }; + inspections.set(workspaceId, inspection); + }, + resume: async (workspaceId: string) => { + resumes.push(workspaceId); + inspection = { status: "ready", cwd: projectRoot }; + inspections.set(workspaceId, inspection); + }, + destroy: async (workspaceId: string) => { + destroys.push(workspaceId); + inspection = { status: "missing" }; + inspections.set(workspaceId, inspection); + await service.records.beginWorkspaceDeletion?.(workspaceId); + await service.records.removeWorkspaceRecord?.(workspaceId); + providerWorkspaces.delete(workspaceId); + }, + listRuntimes: () => [{ runtimeId: "fixture", builtin: false, requiresGitProject: false }], + }; + const fixture = { + root, + projectRoot, + creates, + pauses, + resumes, + destroys, + inspections, + providerWorkspaces, + clock, + runtime, + projectSource: options.projectSource, + get inspection() { + return inspection; + }, + set inspection(value: WorkspaceRuntimeInspection) { + inspection = value; + }, + get service() { + return service; + }, + set service(value: ReturnType) { + service = value; + }, + }; + service = createService(fixture); + fixture.service = service; + return fixture; +} + +function createService( + fixture: { + root: string; + projectRoot: string; + providerWorkspaces: Map; + clock: TestClock; + runtime: { + create(input: CreateWorkspaceInput): Promise; + inspect(workspaceId: string): Promise; + pause(workspaceId: string): Promise; + resume(workspaceId: string): Promise; + destroy(workspaceId: string): Promise; + listRuntimes(): readonly { + runtimeId: string; + builtin: boolean; + requiresGitProject: boolean; + }[]; + }; + projectSource?: { + kind: "git"; + url: string; + revision?: string; + subdirectory?: string; + }; + }, + runtimeConfiguration: Readonly> = { + fixture: { type: "command", options: { sentinel: true } }, + }, +) { + return createProviderProbeService({ + filePath: path.join(fixture.root, "provider-probes.json"), + logger: pino({ enabled: false }), + projects: { + get: async (projectId: string) => + projectId === "project-1" + ? { + projectId, + rootPath: fixture.projectRoot, + source: fixture.projectSource, + updatedAt: "2026-08-12T00:00:00.000Z", + archivedAt: null, + } + : null, + }, + runtime: fixture.runtime, + clock: fixture.clock, + runtimeConfiguration, + bindWorkspaceProviderCapability: async (workspaceId) => { + let workspace = fixture.providerWorkspaces.get(workspaceId); + if (!workspace) { + workspace = { cwd: "." } as ProviderWorkspace; + fixture.providerWorkspaces.set(workspaceId, workspace); + } + return workspace; + }, + }); +} + +function placement(input: CreateWorkspaceInput, cwd: string): WorkspaceRuntimePlacement { + return { workspaceId: input.workspaceId, runtimeId: input.runtimeId, cwd }; +} + +interface TestTimer { + callback: () => void | Promise; + at: number; + cleared: boolean; +} + +class TestClock { + private readonly timers: TestTimer[] = []; + + constructor(private time: number) {} + + now = (): Date => new Date(this.time); + + setTimeout = (callback: () => void | Promise, delayMs: number): TestTimer => { + const timer = { callback, at: this.time + delayMs, cleared: false }; + this.timers.push(timer); + return timer; + }; + + clearTimeout = (timer: TestTimer): void => { + timer.cleared = true; + }; + + async advanceBy(milliseconds: number): Promise { + const end = this.time + milliseconds; + while (true) { + const timer = this.timers + .filter((candidate) => !candidate.cleared && candidate.at <= end) + .sort((left, right) => left.at - right.at)[0]; + if (!timer) break; + timer.cleared = true; + this.time = timer.at; + await timer.callback(); + } + this.time = end; + } +} diff --git a/packages/server/src/server/resolve-workspace-id-for-path.test.ts b/packages/server/src/server/resolve-workspace-id-for-path.test.ts index 162fdf1c5a..0d0e39d767 100644 --- a/packages/server/src/server/resolve-workspace-id-for-path.test.ts +++ b/packages/server/src/server/resolve-workspace-id-for-path.test.ts @@ -27,13 +27,26 @@ function createWorkspaceRecord( // The per-id status law is exercised in workspace-directory.test.ts. describe("resolveWorkspaceIdForPath", () => { - test("returns a single id when multiple workspaces share the exact cwd", () => { - const id = resolveWorkspaceIdForPath("/workspace/project", [ - createWorkspaceRecord("/workspace/project", "ws-1"), - createWorkspaceRecord("/workspace/project", "ws-2"), - createWorkspaceRecord("/workspace/other", "ws-3"), - ]); - expect(["ws-1", "ws-2"]).toContain(id); + test("fails closed when multiple workspaces share the exact compatibility cwd", () => { + expect( + resolveWorkspaceIdForPath("/workspace/project", [ + createWorkspaceRecord("/workspace/project", "ws-1"), + createWorkspaceRecord("/workspace/project", "ws-2"), + createWorkspaceRecord("/workspace/other", "ws-3"), + ]), + ).toBeNull(); + }); + + test("uses only an explicit host-visible path for a selected workspace", () => { + const selected = createWorkspaceRecord("/workspace", "docker-workspace"); + selected.runtime = { runtimeId: "docker" }; + selected.hostVisiblePath = null; + expect(resolveWorkspaceIdForPath("/workspace", [selected])).toBeNull(); + + selected.hostVisiblePath = "/host/worktrees/selected"; + expect(resolveWorkspaceIdForPath("/host/worktrees/selected", [selected])).toBe( + "docker-workspace", + ); }); test("resolves an exact archived workspace match for archive-by-path", () => { diff --git a/packages/server/src/server/resolve-workspace-id-for-path.ts b/packages/server/src/server/resolve-workspace-id-for-path.ts index 58b7572cff..7a61bdd02b 100644 --- a/packages/server/src/server/resolve-workspace-id-for-path.ts +++ b/packages/server/src/server/resolve-workspace-id-for-path.ts @@ -12,25 +12,31 @@ import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; // agents under a workspace — those are keyed by `workspaceId`, and git facts // derive from a workspace's OWN cwd (id → cwd). // -// Resolution: an exact directory match wins; otherwise the deepest enclosing -// workspace directory, never the home directory; null when nothing encloses it. +// Selected records participate only through their explicit host-visible-path +// projection. Resolution fails closed on ambiguity. Otherwise an exact path wins, +// then the deepest enclosing path, never the home directory. export function resolveWorkspaceIdForPath( cwd: string, workspaces: Iterable, ): string | null { const workspaceRecords = Array.from(workspaces); const resolvedCwd = resolve(cwd); - const exactMatch = workspaceRecords.find((workspace) => resolve(workspace.cwd) === resolvedCwd); - if (exactMatch) { - return exactMatch.workspaceId; + const candidates = workspaceRecords.flatMap((workspace) => { + const visiblePath = workspace.runtime ? workspace.hostVisiblePath : workspace.cwd; + return visiblePath ? [{ workspace, visiblePath: resolve(visiblePath) }] : []; + }); + const exactMatches = candidates.filter((candidate) => candidate.visiblePath === resolvedCwd); + if (exactMatches.length !== 0) { + return exactMatches.length === 1 ? (exactMatches[0]?.workspace.workspaceId ?? null) : null; } const userHome = resolve(homedir()); let bestMatchLength = 0; let bestMatch: PersistedWorkspaceRecord | null = null; - for (const workspace of workspaceRecords) { + let bestMatchIsAmbiguous = false; + for (const { workspace, visiblePath } of candidates) { if (workspace.archivedAt) continue; - const workspaceCwd = resolve(workspace.cwd); + const workspaceCwd = visiblePath; if (workspaceCwd === userHome) continue; const prefix = workspaceCwd.endsWith(sep) ? workspaceCwd : `${workspaceCwd}${sep}`; if (!resolvedCwd.startsWith(prefix)) { @@ -39,8 +45,11 @@ export function resolveWorkspaceIdForPath( if (workspaceCwd.length > bestMatchLength) { bestMatchLength = workspaceCwd.length; bestMatch = workspace; + bestMatchIsAmbiguous = false; + } else if (workspaceCwd.length === bestMatchLength) { + bestMatchIsAmbiguous = true; } } - return bestMatch?.workspaceId ?? null; + return bestMatchIsAmbiguous ? null : (bestMatch?.workspaceId ?? null); } diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 89b0171597..dbb3154c8f 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -2,7 +2,10 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { AgentManager } from "../agent/agent-manager.js"; +import { + AgentManager as BaseAgentManager, + type AgentManagerOptions, +} from "../agent/agent-manager.js"; import { AgentStorage } from "../agent/agent-storage.js"; import { createAgentCommand } from "../agent/create-agent/create.js"; import type { @@ -28,6 +31,7 @@ import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js"; import { resolveWorkspaceIdForPath } from "../resolve-workspace-id-for-path.js"; import { createNoopWorkspaceGitService } from "../test-utils/workspace-git-service-stub.js"; +import { resolveHostProviderWorkspace } from "../test-utils/provider-workspace-stub.js"; import { type PersistedWorkspaceRecord, FileBackedProjectRegistry, @@ -64,6 +68,15 @@ const NO_UNATTENDED_SCHEDULE_POLICY: Pick diff --git a/packages/server/src/server/script-status-projection.test.ts b/packages/server/src/server/script-status-projection.test.ts index 3c963f907c..44c806634a 100644 --- a/packages/server/src/server/script-status-projection.test.ts +++ b/packages/server/src/server/script-status-projection.test.ts @@ -544,8 +544,18 @@ describe("script-status-projection", () => { serviceProxy: routeStore, runtimeStore, daemonPort: 6767, - resolveWorkspaceDirectory: async (requestedWorkspaceId) => - requestedWorkspaceId === "workspace-emitter" ? workspace.repoDir : null, + resolveWorkspaceProjection: async (requestedWorkspaceId) => + requestedWorkspaceId === "workspace-emitter" + ? { + workspaceDirectory: "/workspace", + paseoConfig: { + scripts: { + api: { type: "service", command: "npm run api" }, + typecheck: { command: "npm run typecheck" }, + }, + }, + } + : null, logger: createTestLogger(), }); diff --git a/packages/server/src/server/script-status-projection.ts b/packages/server/src/server/script-status-projection.ts index 2d02751fc6..9e9dbba466 100644 --- a/packages/server/src/server/script-status-projection.ts +++ b/packages/server/src/server/script-status-projection.ts @@ -278,7 +278,7 @@ export function createScriptStatusEmitter({ runtimeStore, daemonPort, serviceProxyPublicBaseUrl, - resolveWorkspaceDirectory, + resolveWorkspaceProjection, logger, }: { sessions: () => SessionEmitter[]; @@ -286,13 +286,18 @@ export function createScriptStatusEmitter({ runtimeStore: WorkspaceScriptRuntimeStore; daemonPort: number | null | (() => number | null); serviceProxyPublicBaseUrl?: string | null; - resolveWorkspaceDirectory: (workspaceId: string) => string | null | Promise; + resolveWorkspaceProjection: ( + workspaceId: string, + ) => + | { workspaceDirectory: string; paseoConfig: PaseoConfig | null } + | null + | Promise<{ workspaceDirectory: string; paseoConfig: PaseoConfig | null } | null>; logger: Logger; }): (workspaceId: string, scripts: ScriptHealthEntry[]) => void { return (workspaceId, scripts) => { void (async () => { - const workspaceDirectory = await resolveWorkspaceDirectory(workspaceId); - if (!workspaceDirectory) { + const projection = await resolveWorkspaceProjection(workspaceId); + if (!projection) { return; } @@ -303,8 +308,8 @@ export function createScriptStatusEmitter({ const projected = buildWorkspaceScriptPayloads({ workspaceId, - workspaceDirectory, - paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger), + workspaceDirectory: projection.workspaceDirectory, + paseoConfig: projection.paseoConfig, serviceProxy, runtimeStore, daemonPort: resolvedDaemonPort, @@ -320,6 +325,8 @@ export function createScriptStatusEmitter({ for (const session of sessions()) { session.emit(message); } - })(); + })().catch((error) => { + logger.warn({ err: error, workspaceId }, "Failed to emit workspace script status"); + }); }; } diff --git a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts index 94e45d7452..d920293d45 100644 --- a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts +++ b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts @@ -89,6 +89,18 @@ async function expectWorktreePresentInList(repoDir: string, worktreePath: string .toBe(true); } +async function expectWorktreeAbsentFromList(repoDir: string, worktreePath: string): Promise { + await expect + .poll( + async () => { + const listed = await ctx.client.getPaseoWorktreeList({ cwd: repoDir }); + return listed.worktrees.map((worktree) => worktree.worktreePath).includes(worktreePath); + }, + { timeout: 10_000, interval: 100 }, + ) + .toBe(false); +} + async function expectWorktreeListEmpty(repoDir: string): Promise { const listed = await ctx.client.getPaseoWorktreeList({ cwd: repoDir }); expect(listed.worktrees).toEqual([]); @@ -98,6 +110,7 @@ async function createAgentInBranchOffWorktree(options?: { autoArchive?: boolean; branchName?: string; repoDir?: string; + startIdle?: boolean; }): Promise<{ repoDir: string; agentId: string; worktreePath: string }> { const repoDir = options?.repoDir ?? createGitRepo(); const branchName = options?.branchName ?? `agent-lifecycle-${Date.now()}`; @@ -112,7 +125,7 @@ async function createAgentInBranchOffWorktree(options?: { base: "main", }, ...(options?.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}), - initialPrompt: "Say done.", + ...(options?.startIdle ? {} : { initialPrompt: "Say done." }), }); return { repoDir, agentId: created.id, worktreePath: created.cwd }; } @@ -156,10 +169,10 @@ test("create_agent_request creates a worktree and auto-archives both after the f await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false); // Archived tabs can continue asking for history. These reads must not recreate // the removed workspace observation or compromise the next agent lifecycle. - const staleTimelineReads = await Promise.allSettled( + await Promise.allSettled( Array.from({ length: 10 }, () => ctx.client.fetchAgentTimeline(created.id, { limit: 20 })), ); - expect(staleTimelineReads.every((result) => result.status === "rejected")).toBe(true); + expect(existsSync(created.cwd)).toBe(false); const subsequent = await ctx.client.createAgent({ config: { ...getFullAccessConfig("codex"), cwd: repoDir }, initialPrompt: "Say done.", @@ -335,19 +348,19 @@ test("create_agent_request with worktree but no autoArchive leaves agent and wor await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath }); }); -test("archiving a created worktree removes the directory on last reference", async () => { +test("archiving a created worktree releases its backing for restore", async () => { const created = await createAgentInBranchOffWorktree(); await ctx.client.waitForFinish(created.agentId, 10000); await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath }); await expectAgentAbsentFromActiveList(created.agentId); - await expectWorktreeListEmpty(created.repoDir); + await expectWorktreeAbsentFromList(created.repoDir, created.worktreePath); expect(existsSync(created.worktreePath)).toBe(false); -}); +}, 30_000); test("auto-archiving a created worktree keeps the directory when a sibling workspace references it", async () => { - const created = await createAgentInBranchOffWorktree({ autoArchive: true }); + const created = await createAgentInBranchOffWorktree({ autoArchive: true, startIdle: true }); // Create a sibling workspace that shares the same backing directory. const sibling = await ctx.client.createWorkspace({ @@ -358,6 +371,7 @@ test("auto-archiving a created worktree keeps the directory when a sibling works throw new Error(sibling.error ?? "Failed to create sibling workspace"); } + await ctx.client.sendMessage(created.agentId, "Say done."); await ctx.client.waitForFinish(created.agentId, 10000); await expectAgentAbsentFromActiveList(created.agentId); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 13f0511d37..0088db14a7 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -50,6 +50,15 @@ import { } from "../services/github-service.js"; import type { CheckDetails, ForgeService } from "../services/forge-service.js"; import type { GitHubPullRequestStatusFacts } from "../services/github-facts.js"; +import { bindWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; +import { createStub } from "./test-utils/class-mocks.js"; +import type { WorkspaceRuntimeService } from "./workspace-runtime/index.js"; + +const REQUEST_WORKTREE_CWD = resolvePath("/tmp/request-worktree"); +const BASE_WORKTREE_CWD = resolvePath("/tmp/base-worktree"); +const SERVICE_WORKTREE_CWD = resolvePath("/tmp/service-worktree"); +const TEST_REPO_CWD = resolvePath("/tmp/repo"); +const TEST_WORKSPACE_CWD = resolvePath("/tmp/workspace"); interface SessionHandlerInternals { interruptAgentIfRunning(agentId: string): Promise; @@ -240,7 +249,6 @@ vi.mock("../utils/checkout-git.js", async (importOriginal) => { mergeToBase: checkoutGitMocks.mergeToBase, pullCurrentBranch: checkoutGitMocks.pullCurrentBranch, pushCurrentBranch: checkoutGitMocks.pushCurrentBranch, - renameCurrentBranch: checkoutGitMocks.renameCurrentBranch, resolveBranchCheckout: checkoutGitMocks.resolveBranchCheckout, warmCheckoutShortstatInBackground: checkoutGitMocks.warmCheckoutShortstatInBackground, }; @@ -284,7 +292,10 @@ interface SessionForTestOptions { agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown }; agentStorage?: { [K in keyof SessionOptions["agentStorage"]]?: unknown }; github?: Partial; - checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType }; + checkoutDiffManager?: { + scheduleRefreshForCwd: ReturnType; + scheduleRefreshForWorkspace?: ReturnType; + }; workspaceGitService?: { getCheckout?: ReturnType; getCheckoutDiff?: ReturnType; @@ -301,6 +312,7 @@ interface SessionForTestOptions { getProjectSlug?: ReturnType; }; workspaceRegistry?: { get: ReturnType }; + workspaceRuntime?: WorkspaceRuntimeService; projectRegistry?: Partial; terminalManager?: SessionOptions["terminalManager"]; serviceProxy?: SessionOptions["serviceProxy"]; @@ -331,10 +343,41 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { createPullRequest: vi.fn(), mergePullRequest: vi.fn(), }; - const checkoutDiffManager = options.checkoutDiffManager ?? { + const legacyCheckoutDiffManager = options.checkoutDiffManager ?? { scheduleRefreshForCwd: vi.fn(), }; + const checkoutDiffManager = { + ...legacyCheckoutDiffManager, + scheduleRefreshForWorkspace: + legacyCheckoutDiffManager.scheduleRefreshForWorkspace ?? + vi.fn((workspaceGit: { cwd: string }) => + legacyCheckoutDiffManager.scheduleRefreshForCwd(workspaceGit.cwd), + ), + }; + const boundWorkspaceGit = new Map>(); const workspaceGitService = { + bindLegacy(cwd: string) { + let bound = boundWorkspaceGit.get(`legacy:${cwd}`); + if (!bound) { + bound = bindWorkspaceGitService( + workspaceGitService as unknown as SessionOptions["workspaceGitService"], + cwd, + ); + boundWorkspaceGit.set(`legacy:${cwd}`, bound); + } + return bound; + }, + bindWorkspace({ workspaceId, cwd }: { workspaceId: string; cwd: string }) { + let bound = boundWorkspaceGit.get(workspaceId); + if (!bound) { + bound = bindWorkspaceGitService( + workspaceGitService as unknown as SessionOptions["workspaceGitService"], + cwd, + ); + boundWorkspaceGit.set(workspaceId, bound); + } + return bound; + }, getCheckout: vi.fn(), getCheckoutDiff: vi.fn(), getSnapshot: vi.fn(), @@ -350,7 +393,56 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { // Mirror production: invalidateForge resolves the forge and busts the // adapter's cache. The resolved forge here is github, so delegate to it. invalidateForge: vi.fn((cwd: string) => github.invalidate({ cwd })), + refresh: vi.fn(async (cwd: string) => { + workspaceGitService.invalidateForge(cwd); + await workspaceGitService.getSnapshot(cwd, { + force: true, + includeForge: true, + reason: "manual-refresh", + }); + }), getProjectSlug: vi.fn(), + commit: vi.fn((cwd: string, input: { message: string; addAll: boolean }) => + checkoutGitMocks.commitChanges(cwd, input), + ), + mergeToBase: vi.fn((cwd: string, input: { baseRef?: string; mode?: "merge" | "squash" }) => + checkoutGitMocks.mergeToBase(cwd, input, { + paseoHome: options.paseoHome ?? "/tmp/paseo-home", + }), + ), + mergeFromBase: vi.fn((cwd: string, input: { baseRef?: string }) => + checkoutGitMocks.mergeFromBase(cwd, input), + ), + pull: vi.fn((cwd: string) => checkoutGitMocks.pullCurrentBranch(cwd)), + push: vi.fn((cwd: string) => checkoutGitMocks.pushCurrentBranch(cwd)), + renameBranch: vi.fn((cwd: string, branch: string) => + checkoutGitMocks.renameCurrentBranch(cwd, branch), + ), + switchBranch: vi.fn((cwd: string, branch: string) => + checkoutGitMocks.checkoutResolvedBranch({ + cwd, + resolution: { kind: "local", name: branch }, + }), + ), + createBranch: vi.fn((cwd: string, input: { branch: string; baseRef: string }) => + gitCommandMocks.runGitCommand(["checkout", "-b", input.branch, input.baseRef], { + cwd, + timeout: 120_000, + }), + ), + fetch: vi.fn(), + stashPush: vi.fn((cwd: string, message: string) => + gitCommandMocks.runGitCommand(["stash", "push", "--include-untracked", "-m", message], { + cwd, + timeout: 120_000, + }), + ), + stashPop: vi.fn((cwd: string, index: number) => + gitCommandMocks.runGitCommand(["stash", "pop", `stash@{${index}}`], { + cwd, + timeout: 120_000, + }), + ), ...options.workspaceGitService, }; const messages = options.messages ?? []; @@ -395,6 +487,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { get: vi.fn(), list: vi.fn().mockResolvedValue([]), }, + workspaceRuntime: options.workspaceRuntime, scheduleService: asScheduleService(), checkoutDiffManager: asCheckoutDiffManager(checkoutDiffManager), github: asGitHubService(github), @@ -1816,7 +1909,7 @@ describe("session provider refresh cwd routing", () => { test("get_providers_snapshot_request forwards cwd to the provider authority", async () => { const messages: unknown[] = []; const workspaceCwd = resolvePath("/tmp/session-provider-snapshot"); - const { manager: providerSnapshotManager, getSnapshot } = createProviderSnapshotManagerStub(); + const { manager: providerSnapshotManager, readSnapshot } = createProviderSnapshotManagerStub(); const session = createSessionForTest({ messages, providerSnapshotManager }); await session.handleMessage({ @@ -1825,7 +1918,7 @@ describe("session provider refresh cwd routing", () => { requestId: "snapshot-workspace", }); - expect(getSnapshot).toHaveBeenCalledWith(workspaceCwd); + expect(readSnapshot).toHaveBeenCalledWith({ cwd: workspaceCwd }); }); test("preserves legacy model and mode list requests without cwd as global", async () => { @@ -1991,7 +2084,7 @@ describe("session checkout merge handling", () => { const checkoutDiffManager = { scheduleRefreshForCwd: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue( - createWorkspaceGitSnapshot("/tmp/request-worktree", { + createWorkspaceGitSnapshot(REQUEST_WORKTREE_CWD, { git: { isGit: true, baseRef: "main", @@ -2007,36 +2100,36 @@ describe("session checkout merge handling", () => { messages, }); - checkoutGitMocks.mergeToBase.mockResolvedValue("/tmp/base-worktree"); + checkoutGitMocks.mergeToBase.mockResolvedValue(BASE_WORKTREE_CWD); await session.handleMessage({ type: "checkout_merge_request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, baseRef: "main", requestId: "request-1", }); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree"); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD); expect(checkoutGitMocks.getCheckoutStatus).not.toHaveBeenCalled(); expect(checkoutGitMocks.mergeToBase).toHaveBeenCalledWith( - "/tmp/request-worktree", + REQUEST_WORKTREE_CWD, { baseRef: "main", mode: "merge", }, { paseoHome: "/tmp/paseo-home" }, ); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/base-worktree", { + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(BASE_WORKTREE_CWD, { force: true, reason: "merge-to-base", }); expect(github.invalidate).toHaveBeenCalledTimes(1); - expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/base-worktree" }); - expect(checkoutDiffManager.scheduleRefreshForCwd).toHaveBeenCalledWith("/tmp/request-worktree"); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: BASE_WORKTREE_CWD }); + expect(checkoutDiffManager.scheduleRefreshForCwd).toHaveBeenCalledWith(BASE_WORKTREE_CWD); expect(messages).toContainEqual({ type: "checkout_merge_response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, success: true, error: null, requestId: "request-1", @@ -2048,7 +2141,7 @@ describe("session checkout merge handling", () => { const messages: unknown[] = []; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue( - createWorkspaceGitSnapshot("/tmp/request-worktree", { + createWorkspaceGitSnapshot(REQUEST_WORKTREE_CWD, { git: { isDirty: true, }, @@ -2059,17 +2152,17 @@ describe("session checkout merge handling", () => { await session.handleMessage({ type: "checkout_merge_from_base_request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, baseRef: "main", requireCleanTarget: true, requestId: "request-merge-from-base", }); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree"); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD); expect(messages).toContainEqual({ type: "checkout_merge_from_base_response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, success: false, error: { code: "UNKNOWN", @@ -2085,7 +2178,7 @@ describe("session checkout merge handling", () => { const github = { invalidate: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue( - createWorkspaceGitSnapshot("/tmp/request-worktree", { + createWorkspaceGitSnapshot(REQUEST_WORKTREE_CWD, { git: { isDirty: false, }, @@ -2097,25 +2190,25 @@ describe("session checkout merge handling", () => { await session.handleMessage({ type: "checkout_merge_from_base_request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, baseRef: "main", requireCleanTarget: true, requestId: "request-merge-from-base-success", }); - expect(checkoutGitMocks.mergeFromBase).toHaveBeenCalledWith("/tmp/request-worktree", { + expect(checkoutGitMocks.mergeFromBase).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD, { baseRef: "main", requireCleanTarget: true, }); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD, { force: true, reason: "merge-from-base", }); - expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: REQUEST_WORKTREE_CWD }); expect(messages).toContainEqual({ type: "checkout_merge_from_base_response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, success: true, error: null, requestId: "request-merge-from-base-success", @@ -2210,26 +2303,26 @@ diff --git a/file.txt b/file.txt await session.handleMessage({ type: "checkout_commit_request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, message: "Ship it", addAll: true, requestId: "request-commit", }); - expect(checkoutGitMocks.commitChanges).toHaveBeenCalledWith("/tmp/request-worktree", { + expect(checkoutGitMocks.commitChanges).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD, { message: "Ship it", addAll: true, }); expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(1); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD, { force: true, reason: "commit-changes", }); - expect(checkoutDiffManager.scheduleRefreshForCwd).toHaveBeenCalledWith("/tmp/request-worktree"); + expect(checkoutDiffManager.scheduleRefreshForCwd).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD); expect(messages).toContainEqual({ type: "checkout_commit_response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, success: true, error: null, requestId: "request-commit", @@ -2264,14 +2357,14 @@ diff --git a/file.txt b/file.txt await session.handleMessage({ type: "checkout_commit_request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, message: "", addAll: true, requestId: "request-generated-commit", }); expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledTimes(1); - expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith("/tmp/request-worktree", { + expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD, { mode: "uncommitted", includeStructured: true, }); @@ -2284,14 +2377,14 @@ diff --git a/file.txt b/file.txt }), }), ); - expect(checkoutGitMocks.commitChanges).toHaveBeenCalledWith("/tmp/request-worktree", { + expect(checkoutGitMocks.commitChanges).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD, { message: "Update file", addAll: true, }); expect(messages).toContainEqual({ type: "checkout_commit_response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, success: true, error: null, requestId: "request-generated-commit", @@ -2369,20 +2462,20 @@ diff --git a/file.txt b/file.txt await session.handleMessage({ type: "checkout_commit_request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, message: "", addAll: true, requestId: "request-generated-commit-fallback", }); - expect(checkoutGitMocks.commitChanges).toHaveBeenCalledWith("/tmp/request-worktree", { + expect(checkoutGitMocks.commitChanges).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD, { message: "Update files", addAll: true, }); expect(messages).toContainEqual({ type: "checkout_commit_response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, success: true, error: null, requestId: "request-generated-commit-fallback", @@ -2398,7 +2491,7 @@ diff --git a/file.txt b/file.txt await session.handleMessage({ type: "checkout_commit_request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, message: "Ship it", addAll: true, requestId: "request-commit-failure", @@ -2408,7 +2501,7 @@ diff --git a/file.txt b/file.txt expect(messages).toContainEqual({ type: "checkout_commit_response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, success: false, error: { code: "UNKNOWN", @@ -2533,7 +2626,7 @@ diff --git a/file.txt b/file.txt await session.handleMessage({ type: "checkout_pr_create_request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, baseRef: "main", title: "", body: "", @@ -2541,7 +2634,7 @@ diff --git a/file.txt b/file.txt }); expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledTimes(1); - expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith("/tmp/request-worktree", { + expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD, { mode: "base", baseRef: "main", includeStructured: true, @@ -2556,7 +2649,7 @@ diff --git a/file.txt b/file.txt }), ); expect(checkoutGitMocks.createPullRequest).toHaveBeenCalledWith( - "/tmp/request-worktree", + REQUEST_WORKTREE_CWD, { title: "Update file", body: "Updates file.", @@ -2567,7 +2660,7 @@ diff --git a/file.txt b/file.txt expect(messages).toContainEqual({ type: "checkout_pr_create_response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, url: "https://github.com/getpaseo/paseo/pull/1", number: 1, error: null, @@ -2672,7 +2765,7 @@ diff --git a/file.txt b/file.txt await session.handleMessage({ type: "checkout_pr_create_request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, baseRef: "main", title: "", body: "", @@ -2680,7 +2773,7 @@ diff --git a/file.txt b/file.txt }); expect(checkoutGitMocks.createPullRequest).toHaveBeenCalledWith( - "/tmp/request-worktree", + REQUEST_WORKTREE_CWD, { title: "Update changes", body: "Automated PR generated by Paseo.", @@ -2691,7 +2784,7 @@ diff --git a/file.txt b/file.txt expect(messages).toContainEqual({ type: "checkout_pr_create_response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, url: "https://github.com/getpaseo/paseo/pull/9", number: 9, error: null, @@ -2714,22 +2807,22 @@ diff --git a/file.txt b/file.txt await session.handleMessage({ type: "checkout_pr_create_request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, baseRef: "main", title: "Update file", body: "Updates file.", requestId: "request-pr-create", }); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD, { force: true, reason: "create-pr", }); - expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: REQUEST_WORKTREE_CWD }); expect(messages).toContainEqual({ type: "checkout_pr_create_response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, url: "https://github.com/getpaseo/paseo/pull/2", number: 2, error: null, @@ -2777,13 +2870,13 @@ describe("session checkout pull request merge", () => { await session.handleMessage({ type: "checkout_pr_merge_request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, mergeMethod: "squash", requestId: "request-pr-merge", }); expect(github.mergePullRequest).toHaveBeenCalledWith({ - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, prNumber: 42, mergeMethod: "squash", status: { @@ -2808,20 +2901,20 @@ describe("session checkout pull request merge", () => { }, }, }); - expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, "/tmp/request-worktree", { + expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, REQUEST_WORKTREE_CWD, { force: true, includeForge: true, reason: "merge-pr-validation", }); - expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, "/tmp/request-worktree", { + expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, REQUEST_WORKTREE_CWD, { force: true, reason: "merge-pr", }); - expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: REQUEST_WORKTREE_CWD }); expect(messages).toContainEqual({ type: "checkout_pr_merge_response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, success: true, error: null, requestId: "request-pr-merge", @@ -2876,13 +2969,13 @@ describe("session checkout pull request merge", () => { await session.handleMessage({ type: "checkout_pr_merge_request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, mergeMethod: "squash", requestId: "request-pr-merge-fresh-blocked", }); expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(1); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD, { force: true, includeForge: true, reason: "merge-pr-validation", @@ -2898,7 +2991,7 @@ describe("session checkout pull request merge", () => { expect(messages).toContainEqual({ type: "checkout_pr_merge_response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, success: false, error: { code: "UNKNOWN", @@ -2929,13 +3022,13 @@ describe("session checkout pull request merge", () => { await session.handleMessage({ type: "checkout_pr_merge_request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, mergeMethod: "squash", requestId: "request-pr-merge-missing-github-facts", }); expect(github.mergePullRequest).toHaveBeenCalledWith({ - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, prNumber: 42, mergeMethod: "squash", status: { @@ -2943,20 +3036,20 @@ describe("session checkout pull request merge", () => { mergeable: "MERGEABLE", }, }); - expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); - expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, "/tmp/request-worktree", { + expect(github.invalidate).toHaveBeenCalledWith({ cwd: REQUEST_WORKTREE_CWD }); + expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, REQUEST_WORKTREE_CWD, { force: true, includeForge: true, reason: "merge-pr-validation", }); - expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, "/tmp/request-worktree", { + expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, REQUEST_WORKTREE_CWD, { force: true, reason: "merge-pr", }); expect(messages).toContainEqual({ type: "checkout_pr_merge_response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, success: true, error: null, requestId: "request-pr-merge-missing-github-facts", @@ -3001,7 +3094,7 @@ describe("session checkout pull request merge", () => { await session.handleMessage({ type: "checkout_pr_merge_request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, mergeMethod: "merge", requestId: "request-pr-merge-failure", }); @@ -3009,7 +3102,7 @@ describe("session checkout pull request merge", () => { expect(messages).toContainEqual({ type: "checkout_pr_merge_response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, success: false, error: { code: "UNKNOWN", @@ -3068,14 +3161,14 @@ describe("session checkout pull request auto-merge", () => { await session.handleMessage({ type: "checkout.forge.set_auto_merge.request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, enabled: true, mergeMethod: "squash", requestId: "request-pr-auto-merge-enable", }); expect(github.enablePullRequestAutoMerge).toHaveBeenCalledWith({ - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, prNumber: 42, mergeMethod: "squash", status: { @@ -3084,20 +3177,20 @@ describe("session checkout pull request auto-merge", () => { forgeSpecific: autoMergeGithubFacts(), }, }); - expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, "/tmp/request-worktree", { + expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, REQUEST_WORKTREE_CWD, { force: true, includeForge: true, reason: "auto-merge-validation", }); - expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, "/tmp/request-worktree", { + expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, REQUEST_WORKTREE_CWD, { force: true, reason: "enable-pr-auto-merge", }); - expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: REQUEST_WORKTREE_CWD }); expect(messages).toContainEqual({ type: "checkout.forge.set_auto_merge.response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, enabled: true, success: true, error: null, @@ -3137,13 +3230,13 @@ describe("session checkout pull request auto-merge", () => { await session.handleMessage({ type: "checkout.forge.set_auto_merge.request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, enabled: false, requestId: "request-pr-auto-merge-disable", }); expect(github.disablePullRequestAutoMerge).toHaveBeenCalledWith({ - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, prNumber: 42, status: { number: 42, @@ -3158,20 +3251,20 @@ describe("session checkout pull request auto-merge", () => { }), }, }); - expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, "/tmp/request-worktree", { + expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, REQUEST_WORKTREE_CWD, { force: true, includeForge: true, reason: "auto-merge-validation", }); - expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, "/tmp/request-worktree", { + expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, REQUEST_WORKTREE_CWD, { force: true, reason: "disable-pr-auto-merge", }); - expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: REQUEST_WORKTREE_CWD }); expect(messages).toContainEqual({ type: "checkout.forge.set_auto_merge.response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, enabled: false, success: true, error: null, @@ -3200,7 +3293,7 @@ describe("session checkout pull request auto-merge", () => { await session.handleMessage({ type: "checkout.forge.set_auto_merge.request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, enabled: true, mergeMethod: "merge", requestId: "request-pr-auto-merge-failure", @@ -3209,7 +3302,7 @@ describe("session checkout pull request auto-merge", () => { expect(messages).toContainEqual({ type: "checkout.forge.set_auto_merge.response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, enabled: true, success: false, error: { @@ -3252,7 +3345,7 @@ describe("session checkout pull request auto-merge", () => { await session.handleMessage({ type: "checkout.forge.set_auto_merge.request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, enabled: true, mergeMethod: "squash", requestId: "request-pr-auto-merge-method-disabled", @@ -3262,7 +3355,7 @@ describe("session checkout pull request auto-merge", () => { // effect, so it is invoked but the mutation never completes (no invalidate). expect(github.enablePullRequestAutoMerge).toHaveBeenCalled(); expect(github.invalidate).not.toHaveBeenCalled(); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD, { force: true, includeForge: true, reason: "auto-merge-validation", @@ -3270,7 +3363,7 @@ describe("session checkout pull request auto-merge", () => { expect(messages).toContainEqual({ type: "checkout.forge.set_auto_merge.response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, enabled: true, success: false, error: { @@ -3313,7 +3406,7 @@ describe("session checkout pull request auto-merge", () => { await session.handleMessage({ type: "checkout.forge.set_auto_merge.request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, enabled: false, requestId: "request-pr-auto-merge-disable-forbidden", }); @@ -3322,7 +3415,7 @@ describe("session checkout pull request auto-merge", () => { // effect, so it is invoked but the mutation never completes (no invalidate). expect(github.disablePullRequestAutoMerge).toHaveBeenCalled(); expect(github.invalidate).not.toHaveBeenCalled(); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD, { force: true, includeForge: true, reason: "auto-merge-validation", @@ -3330,7 +3423,7 @@ describe("session checkout pull request auto-merge", () => { expect(messages).toContainEqual({ type: "checkout.forge.set_auto_merge.response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, enabled: false, success: false, error: { @@ -3373,7 +3466,7 @@ describe("session checkout pull request auto-merge", () => { await session.handleMessage({ type: "checkout.forge.set_auto_merge.request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, enabled: false, mergeMethod: "squash", requestId: "request-pr-auto-merge-disable-with-method", @@ -3384,7 +3477,7 @@ describe("session checkout pull request auto-merge", () => { expect(messages).toContainEqual({ type: "checkout.forge.set_auto_merge.response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, enabled: false, success: false, error: { @@ -3407,20 +3500,20 @@ describe("session checkout pull and push handling", () => { await session.handleMessage({ type: "checkout_pull_request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, requestId: "request-pull", }); - expect(checkoutGitMocks.pullCurrentBranch).toHaveBeenCalledWith("/tmp/request-worktree"); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + expect(checkoutGitMocks.pullCurrentBranch).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD, { force: true, reason: "pull", }); - expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: REQUEST_WORKTREE_CWD }); expect(messages).toContainEqual({ type: "checkout_pull_response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, success: true, error: null, requestId: "request-pull", @@ -3437,20 +3530,20 @@ describe("session checkout pull and push handling", () => { await session.handleMessage({ type: "checkout_push_request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, requestId: "request-push", }); - expect(checkoutGitMocks.pushCurrentBranch).toHaveBeenCalledWith("/tmp/request-worktree"); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + expect(checkoutGitMocks.pushCurrentBranch).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD, { force: true, reason: "push", }); - expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: REQUEST_WORKTREE_CWD }); expect(messages).toContainEqual({ type: "checkout_push_response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, success: true, error: null, requestId: "request-push", @@ -3474,21 +3567,21 @@ describe("session checkout refresh handling", () => { await session.handleMessage({ type: "checkout.refresh.request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, requestId: "request-refresh", }); - expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + expect(github.invalidate).toHaveBeenCalledWith({ cwd: REQUEST_WORKTREE_CWD }); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD, { force: true, includeForge: true, reason: "manual-refresh", }); - expect(checkoutDiffManager.scheduleRefreshForCwd).toHaveBeenCalledWith("/tmp/request-worktree"); + expect(checkoutDiffManager.scheduleRefreshForCwd).toHaveBeenCalledWith(REQUEST_WORKTREE_CWD); expect(messages).toContainEqual({ type: "checkout.refresh.response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, success: true, error: null, requestId: "request-refresh", @@ -3512,7 +3605,7 @@ describe("session checkout refresh handling", () => { await session.handleMessage({ type: "checkout.refresh.request", - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, requestId: "request-refresh-error", }); @@ -3520,7 +3613,7 @@ describe("session checkout refresh handling", () => { expect(messages).toContainEqual({ type: "checkout.refresh.response", payload: { - cwd: "/tmp/request-worktree", + cwd: REQUEST_WORKTREE_CWD, success: false, error: { code: "UNKNOWN", message: "not a git repository" }, requestId: "request-refresh-error", @@ -3533,26 +3626,26 @@ describe("session checkout status handling", () => { test("returns checkout status from the workspace git service snapshot", async () => { const messages: unknown[] = []; const workspaceGitService = { - getSnapshot: vi.fn().mockResolvedValue(createWorkspaceGitSnapshot("/tmp/service-worktree")), + getSnapshot: vi.fn().mockResolvedValue(createWorkspaceGitSnapshot(SERVICE_WORKTREE_CWD)), peekSnapshot: vi.fn(), }; const session = createSessionForTest({ workspaceGitService, messages }); await session.handleMessage({ type: "checkout_status_request", - cwd: "/tmp/service-worktree", + cwd: SERVICE_WORKTREE_CWD, requestId: "request-status", }); expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(1); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/service-worktree"); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(SERVICE_WORKTREE_CWD); expect(checkoutGitMocks.getCheckoutStatus).not.toHaveBeenCalled(); expect(messages).toContainEqual({ type: "checkout_status_response", payload: { - cwd: "/tmp/service-worktree", + cwd: SERVICE_WORKTREE_CWD, isGit: true, - repoRoot: "/tmp/service-worktree", + repoRoot: SERVICE_WORKTREE_CWD, mainRepoRoot: null, currentBranch: "feature/service", isDirty: true, @@ -3765,7 +3858,7 @@ describe("session workspace descriptors", () => { const workspaceGitService = { getSnapshot: vi.fn(), peekSnapshot: vi.fn(() => - createWorkspaceGitSnapshot("/tmp/workspace", { + createWorkspaceGitSnapshot(TEST_WORKSPACE_CWD, { git: { diffStat: { additions: 7, deletions: 2 } }, }), ), @@ -3780,19 +3873,19 @@ describe("session workspace descriptors", () => { { workspaceId: "workspace-1", projectId: "project-1", - cwd: "/tmp/workspace", + cwd: TEST_WORKSPACE_CWD, kind: "checkout", displayName: "Workspace", }, { projectId: "project-1", - rootPath: "/tmp/workspace", + rootPath: TEST_WORKSPACE_CWD, displayName: "Project", kind: "git", }, ); - expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/workspace"); + expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith(TEST_WORKSPACE_CWD); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(checkoutGitMocks.getCachedCheckoutShortstat).not.toHaveBeenCalled(); expect(checkoutGitMocks.warmCheckoutShortstatInBackground).not.toHaveBeenCalled(); @@ -3801,7 +3894,7 @@ describe("session workspace descriptors", () => { test("does not cold-load git data while describing a workspace", async () => { const workspaceGitService = { - getSnapshot: vi.fn().mockResolvedValue(createWorkspaceGitSnapshot("/tmp/workspace")), + getSnapshot: vi.fn().mockResolvedValue(createWorkspaceGitSnapshot(TEST_WORKSPACE_CWD)), peekSnapshot: vi.fn(() => null), }; const session = createSessionForTest({ workspaceGitService }); @@ -3810,19 +3903,19 @@ describe("session workspace descriptors", () => { { workspaceId: "workspace-1", projectId: "project-1", - cwd: "/tmp/workspace", + cwd: TEST_WORKSPACE_CWD, kind: "checkout", displayName: "Workspace", }, { projectId: "project-1", - rootPath: "/tmp/workspace", + rootPath: TEST_WORKSPACE_CWD, displayName: "Project", kind: "git", }, ); - expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/workspace"); + expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith(TEST_WORKSPACE_CWD); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(descriptor.diffStat).toBeNull(); expect(descriptor.gitRuntime).toBeUndefined(); @@ -3843,13 +3936,13 @@ describe("session branch validation", () => { await session.handleMessage({ type: "validate_branch_request", - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, branchName: "feature", requestId: "request-validate-service", }); expect(workspaceGitService.validateBranchRef).toHaveBeenCalledTimes(1); - expect(workspaceGitService.validateBranchRef).toHaveBeenCalledWith("/tmp/repo", "feature"); + expect(workspaceGitService.validateBranchRef).toHaveBeenCalledWith(TEST_REPO_CWD, "feature"); expect(checkoutGitMocks.resolveBranchCheckout).not.toHaveBeenCalled(); expect(messages).toContainEqual({ type: "validate_branch_response", @@ -3913,7 +4006,7 @@ describe("session checkout switch branch handling", () => { const github = { invalidate: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue( - createWorkspaceGitSnapshot("/tmp/repo", { + createWorkspaceGitSnapshot(TEST_REPO_CWD, { git: { isDirty: false, }, @@ -3926,24 +4019,24 @@ describe("session checkout switch branch handling", () => { await session.handleMessage({ type: "checkout_switch_branch_request", - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, branch: "release", requestId: "request-switch", }); expect(checkoutGitMocks.checkoutResolvedBranch).toHaveBeenCalledWith({ - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, resolution: { kind: "local", name: "release" }, }); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", { + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(TEST_REPO_CWD, { force: true, reason: "switch-branch", }); - expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/repo" }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: TEST_REPO_CWD }); expect(messages).toContainEqual({ type: "checkout_switch_branch_response", payload: { - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, success: true, branch: "release", source: "local", @@ -3965,7 +4058,7 @@ describe("session checkout rename branch handling", () => { await session.handleMessage({ type: "checkout.rename_branch.request", - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, branch: "Feature Name", requestId: "request-rename-invalid", }); @@ -3975,7 +4068,7 @@ describe("session checkout rename branch handling", () => { expect(messages).toContainEqual({ type: "checkout.rename_branch.response", payload: { - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, success: false, currentBranch: null, error: { @@ -3999,13 +4092,13 @@ describe("session checkout rename branch handling", () => { await session.handleMessage({ type: "checkout.rename_branch.request", - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, branch: "feature/new-name", requestId: "request-rename-failure", }); expect(checkoutGitMocks.renameCurrentBranch).toHaveBeenCalledWith( - "/tmp/repo", + TEST_REPO_CWD, "feature/new-name", ); expect(workspaceGitService.peekSnapshot).not.toHaveBeenCalled(); @@ -4013,7 +4106,7 @@ describe("session checkout rename branch handling", () => { expect(messages).toContainEqual({ type: "checkout.rename_branch.response", payload: { - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, success: false, currentBranch: null, error: { @@ -4030,7 +4123,7 @@ describe("session checkout rename branch handling", () => { const github = { invalidate: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue( - createWorkspaceGitSnapshot("/tmp/repo", { + createWorkspaceGitSnapshot(TEST_REPO_CWD, { git: { currentBranch: "feature/new-name", isDirty: false, @@ -4038,7 +4131,7 @@ describe("session checkout rename branch handling", () => { }), ), peekSnapshot: vi.fn(() => - createWorkspaceGitSnapshot("/tmp/repo", { + createWorkspaceGitSnapshot(TEST_REPO_CWD, { git: { currentBranch: "feature/old-name" }, }), ), @@ -4051,24 +4144,24 @@ describe("session checkout rename branch handling", () => { await session.handleMessage({ type: "checkout.rename_branch.request", - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, branch: "feature/new-name", requestId: "request-rename-success", }); expect(checkoutGitMocks.renameCurrentBranch).toHaveBeenCalledWith( - "/tmp/repo", + TEST_REPO_CWD, "feature/new-name", ); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", { + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(TEST_REPO_CWD, { force: true, reason: "rename-branch", }); - expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/repo" }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: TEST_REPO_CWD }); expect(messages).toContainEqual({ type: "checkout.rename_branch.response", payload: { - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, success: true, currentBranch: "feature/new-name", error: null, @@ -4171,14 +4264,14 @@ describe("session branch suggestions handling", () => { await session.handleMessage({ type: "branch_suggestions_request", - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, query: "service", limit: 5, requestId: "request-branches", }); expect(workspaceGitService.suggestBranchesForCwd).toHaveBeenCalledTimes(1); - expect(workspaceGitService.suggestBranchesForCwd).toHaveBeenCalledWith("/tmp/repo", { + expect(workspaceGitService.suggestBranchesForCwd).toHaveBeenCalledWith(TEST_REPO_CWD, { query: "service", limit: 5, }); @@ -4215,18 +4308,18 @@ describe("session stash list handling", () => { await session.handleMessage({ type: "stash_list_request", - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, paseoOnly: true, requestId: "request-stashes", }); expect(workspaceGitService.listStashes).toHaveBeenCalledTimes(1); - expect(workspaceGitService.listStashes).toHaveBeenCalledWith("/tmp/repo", { + expect(workspaceGitService.listStashes).toHaveBeenCalledWith(TEST_REPO_CWD, { paseoOnly: true, }); expect(messages).toContainEqual({ type: "stash_list_response", - payload: { cwd: "/tmp/repo", entries, error: null, requestId: "request-stashes" }, + payload: { cwd: TEST_REPO_CWD, entries, error: null, requestId: "request-stashes" }, }); }); }); @@ -4246,23 +4339,23 @@ describe("session stash mutation handling", () => { await session.handleMessage({ type: "stash_save_request", - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, branch: "feature", requestId: "request-stash-push", }); expect(gitCommandMocks.runGitCommand).toHaveBeenCalledWith( ["stash", "push", "--include-untracked", "-m", "paseo-auto-stash: feature"], - { cwd: "/tmp/repo", timeout: 120_000 }, + { cwd: TEST_REPO_CWD, timeout: 120_000 }, ); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", { + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(TEST_REPO_CWD, { force: true, reason: "stash-push", }); expect(messages).toContainEqual({ type: "stash_save_response", payload: { - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, success: true, error: null, requestId: "request-stash-push", @@ -4284,23 +4377,23 @@ describe("session stash mutation handling", () => { await session.handleMessage({ type: "stash_pop_request", - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, stashIndex: 0, requestId: "request-stash-pop", }); expect(gitCommandMocks.runGitCommand).toHaveBeenCalledWith(["stash", "pop", "stash@{0}"], { - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, timeout: 120_000, }); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", { + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(TEST_REPO_CWD, { force: true, reason: "stash-pop", }); expect(messages).toContainEqual({ type: "stash_pop_response", payload: { - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, success: true, error: null, requestId: "request-stash-pop", @@ -4312,9 +4405,12 @@ describe("session stash mutation handling", () => { describe("session paseo worktree creation handling", () => { test("forces workspace git refreshes for the source repo and created worktree", async () => { const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) }; - const session = createSessionForTest({ workspaceGitService }); + const session = createSessionForTest({ + workspaceGitService, + workspaceRuntime: createStub({}), + }); paseoWorktreeServiceMocks.createPaseoWorktree.mockResolvedValue({ - repoRoot: "/tmp/repo", + repoRoot: TEST_REPO_CWD, worktree: { branchName: "feature/new-worktree", worktreePath: "/tmp/paseo/worktrees/new-worktree", @@ -4330,12 +4426,12 @@ describe("session paseo worktree creation handling", () => { }); await asSessionInternals(session).createPaseoWorktree({ - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, worktreeSlug: "new-worktree", runSetup: false, }); - expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", { + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(TEST_REPO_CWD, { force: true, reason: "create-worktree", }); @@ -4352,7 +4448,7 @@ describe("session paseo worktree creation handling", () => { describe("session workspace script handling", () => { test("passes the project slug and cached branch into workspace script spawning", async () => { const messages: unknown[] = []; - const snapshot = createWorkspaceGitSnapshot("/tmp/repo", { + const snapshot = createWorkspaceGitSnapshot(TEST_REPO_CWD, { git: { currentBranch: "feature/service-scripts", remoteUrl: "https://github.com/getpaseo/paseo.git", @@ -4365,7 +4461,7 @@ describe("session workspace script handling", () => { const workspaceRegistry = { get: vi.fn().mockResolvedValue({ workspaceId: "workspace-1", - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, }), }; spawnMocks.spawnWorkspaceScript.mockResolvedValue({ @@ -4395,7 +4491,7 @@ describe("session workspace script handling", () => { expect(spawnMocks.spawnWorkspaceScript).toHaveBeenCalledWith( expect.objectContaining({ - repoRoot: "/tmp/repo", + repoRoot: TEST_REPO_CWD, workspaceId: "workspace-1", projectSlug: "paseo", branchName: "feature/service-scripts", @@ -4455,7 +4551,7 @@ describe("session pull request timeline handling", () => { await session.handleMessage({ type: "github_search_request", - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, query: "search", limit: 5, kinds: ["github-pr"], @@ -4463,7 +4559,7 @@ describe("session pull request timeline handling", () => { }); expect(github.searchIssuesAndPrs).toHaveBeenCalledWith({ - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, query: "search", limit: 5, kinds: ["github-pr"], @@ -4509,7 +4605,7 @@ describe("session pull request timeline handling", () => { await session.handleMessage({ type: "forge.search.request", - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, query: "search", limit: 5, kinds: ["change_request"], @@ -4558,7 +4654,7 @@ describe("session pull request timeline handling", () => { await session.handleMessage({ type: "pull_request_timeline_request", - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, prNumber: 42, repoOwner: "getpaseo", repoName: "paseo", @@ -4566,7 +4662,7 @@ describe("session pull request timeline handling", () => { }); expect(github.getPullRequestTimeline).toHaveBeenCalledWith({ - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, prNumber: 42, repoOwner: "getpaseo", repoName: "paseo", @@ -4574,7 +4670,7 @@ describe("session pull request timeline handling", () => { expect(messages).toContainEqual({ type: "pull_request_timeline_response", payload: { - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, prNumber: 42, items: [ { @@ -4617,7 +4713,7 @@ describe("session pull request timeline handling", () => { await session.handleMessage({ type: "pull_request_timeline_request", - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, ...identity, requestId: "request-invalid", }); @@ -4627,7 +4723,7 @@ describe("session pull request timeline handling", () => { expect(messages).toContainEqual({ type: "pull_request_timeline_response", payload: { - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, prNumber: identity.prNumber, items: [], truncated: false, @@ -4652,7 +4748,7 @@ describe("session pull request timeline handling", () => { await session.handleMessage({ type: "pull_request_timeline_request", - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, prNumber: 42, repoOwner: "getpaseo", repoName: "paseo", @@ -4663,7 +4759,7 @@ describe("session pull request timeline handling", () => { expect(messages).toContainEqual({ type: "pull_request_timeline_response", payload: { - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, prNumber: 42, items: [], truncated: false, @@ -4719,7 +4815,7 @@ describe("session pull request timeline handling", () => { await session.handleMessage({ type: "checkout.forge.get_check_details.request", - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, repoOwner: "getpaseo", repoName: "paseo", checkRunId: 12345, @@ -4729,7 +4825,7 @@ describe("session pull request timeline handling", () => { expect(checkDetailRequests).toEqual([ { - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, repoOwner: "getpaseo", repoName: "paseo", checkRunId: 12345, @@ -4740,7 +4836,7 @@ describe("session pull request timeline handling", () => { expect(messages).toContainEqual({ type: "checkout.forge.get_check_details.response", payload: { - cwd: "/tmp/repo", + cwd: TEST_REPO_CWD, success: true, details: { checkRunId: 12345, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 4d6c0b40cb..b9094a63cc 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -65,6 +65,10 @@ import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-uti import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; +import { + createWorkspaceGitDirectory, + type WorkspaceGitDirectory, +} from "./workspace-git-directory.js"; import type { ProjectUpdate } from "./workspace-reconciliation-service.js"; import { CLIENT_SHUTDOWN_RPC_REASON, @@ -132,6 +136,7 @@ import { } from "./workspace-registry-model.js"; import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js"; import { + projectRuntimeSource, resolveProjectDisplayName, resolveWorkspaceDisplayName, resolveWorkspaceName, @@ -142,6 +147,8 @@ import { type WorkspaceMutation, type WorkspaceRegistry, } from "./workspace-registry.js"; +import type { WorkspaceRuntimeService } from "./workspace-runtime/index.js"; +import type { ProviderProbeService } from "./provider-probe/index.js"; import { wrapSpokenInput } from "./voice-config.js"; import { isVoicePermissionAllowed } from "./voice-permission-policy.js"; import { @@ -176,7 +183,6 @@ import { archiveWorkspaceContents, } from "./workspace-archive-service.js"; import type { ServiceProxySubsystem } from "./service-proxy.js"; -import { renameCurrentBranch as renameCurrentBranchDefault } from "../utils/checkout-git.js"; import { createGitMutationService, type GitMutationService, @@ -236,6 +242,7 @@ import { handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest, handlePaseoWorktreeListRequest as handleWorktreeListRequest, handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage, + runWorktreeSetupInBackground, } from "./worktree-session.js"; import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js"; import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js"; @@ -448,15 +455,14 @@ export interface SessionOptions { agentStorage: AgentStorage; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; + workspaceRuntime?: WorkspaceRuntimeService; + providerProbe?: ProviderProbeService; directorySync?: DirectorySyncService; filesystem?: SessionFileSystem; scheduleService: ScheduleService; checkoutDiffManager: CheckoutDiffManager; github?: ForgeService; createAgentMcpTransport?: AgentMcpTransportFactory; - // Injected so tests can substitute the git branch rename without module mocks; - // defaults to the real checkout-git implementation. - renameCurrentBranch?: typeof renameCurrentBranchDefault; workspaceGitService: WorkspaceGitService; workspaceAutoName: WorkspaceAutoName; daemonConfigStore: DaemonConfigStore; @@ -641,11 +647,13 @@ export class Session { private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; + private readonly workspaceRuntime: WorkspaceRuntimeService | undefined; + private readonly providerProbe: ProviderProbeService | undefined; private readonly directorySync: DirectorySyncService; private readonly filesystem: SessionFileSystem; private readonly github: ForgeService; - private readonly renameCurrentBranch: typeof renameCurrentBranchDefault; private readonly workspaceGitService: WorkspaceGitService; + private readonly workspaceGitDirectory: WorkspaceGitDirectory; private readonly workspaceAutoName: WorkspaceAutoName; private readonly gitMutation: GitMutationService; private readonly workspaceProvisioning: WorkspaceProvisioningService; @@ -658,6 +666,7 @@ export class Session { private unsubscribePluginChanges: (() => void) | null = null; private unsubscribeWorkspaceMutations: (() => void) | null = null; private registryMutationQueue: Promise = Promise.resolve(); + private readonly workspacesAwaitingInitialAgent = new Set(); private projectUpdateQueue: Promise = Promise.resolve(); private isCleanedUp = false; private viewedTimelineAgentIds = new Set(); @@ -727,12 +736,13 @@ export class Session { agentStorage, projectRegistry, workspaceRegistry, + workspaceRuntime, + providerProbe, directorySync, filesystem, scheduleService, checkoutDiffManager, github, - renameCurrentBranch, workspaceGitService, workspaceAutoName, daemonConfigStore, @@ -792,16 +802,23 @@ export class Session { downloadTokenStore, paseoHome, logger: this.sessionLogger, + workspaceRuntime, + workspaceRegistry, }); this.agentManager = agentManager; this.agentStorage = agentStorage; this.projectRegistry = projectRegistry; this.workspaceRegistry = workspaceRegistry; + this.workspaceRuntime = workspaceRuntime; + this.providerProbe = providerProbe; this.directorySync = resolveDirectorySync(directorySync); this.filesystem = filesystem ?? nodeSessionFileSystem; this.github = github ?? createGitHubService(); - this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; this.workspaceGitService = workspaceGitService; + this.workspaceGitDirectory = createWorkspaceGitDirectory({ + workspaceRegistry: this.workspaceRegistry, + workspaceGitService: this.workspaceGitService, + }); this.gitMutation = createGitMutationService({ workspaceGitService: this.workspaceGitService, logger: this.sessionLogger, @@ -820,6 +837,10 @@ export class Session { getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId), getProject: (projectId) => this.projectRegistry.get(projectId), isDirectory: (path) => this.filesystem.isDirectory(path), + inspectRuntime: (workspaceId) => { + if (!this.workspaceRuntime) return Promise.resolve("missing"); + return this.workspaceRuntime.inspect(workspaceId).then((inspection) => inspection.status); + }, unarchiveWorkspace: async (workspace) => { await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace); }, @@ -828,12 +849,11 @@ export class Session { host: { emit: (msg) => this.emit(msg), emitWorkspaceUpdateForCwd: (cwd) => this.emitWorkspaceUpdateForCwd(cwd), - handleWorkspaceGitBranchSnapshot: (cwd, branchName) => - this.workspaceGitObserver.handleBranchSnapshot(cwd, branchName), - renameCurrentBranch: (cwd, branch) => this.renameCurrentBranch(cwd, branch), + handleWorkspaceGitBranchSnapshot: (address, branchName) => + this.workspaceGitObserver.handleBranchSnapshot(address, branchName), }, gitMutation: this.gitMutation, - workspaceGitService: this.workspaceGitService, + workspaceGitDirectory: this.workspaceGitDirectory, github: this.github, checkoutDiffManager, gitMetadataGenerator: createGitMetadataGenerator({ @@ -845,18 +865,17 @@ export class Session { getFocusedSelection: (cwd) => this.getFocusedAgentSelectionForCwd(cwd), }), }), - paseoHome: this.paseoHome, - worktreesRoot: this.worktreesRoot, logger: this.sessionLogger, }); this.workspaceGitObserver = createWorkspaceGitObserverService({ - workspaceGitService: this.workspaceGitService, + resolveWorkspaceGit: (workspaceId, cwd) => + this.workspaceGitDirectory.getObservationBinding(workspaceId, cwd), describeWorkspaceRecordWithGitData: (workspace) => this.describeWorkspaceRecordWithGitData(workspace), - emitWorkspaceUpdateForCwd: (cwd) => this.emitWorkspaceUpdateForCwd(cwd), emitWorkspaceUpdateForWorkspaceId: (workspaceId) => this.emitWorkspaceUpdateForWorkspaceId(workspaceId), - emitStatusUpdate: (cwd, snapshot) => this.checkoutSession.emitStatusUpdate(cwd, snapshot), + emitStatusUpdate: (workspaceId, cwd, snapshot) => + this.checkoutSession.emitStatusUpdate(workspaceId, cwd, snapshot), onBranchChanged, logger: this.sessionLogger, }); @@ -955,8 +974,10 @@ export class Session { isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), buildProjectPlacementForWorkspaceId: (workspaceId) => this.buildProjectPlacementForWorkspaceId(workspaceId), - emitWorkspaceUpdateForWorkspaceId: (workspaceId) => - this.emitWorkspaceUpdateForWorkspaceId(workspaceId), + emitWorkspaceUpdateForWorkspaceId: (workspaceId) => { + this.workspacesAwaitingInitialAgent.delete(workspaceId); + return this.emitWorkspaceUpdateForWorkspaceId(workspaceId); + }, sequenceAgentUpdate: (payload, agent, project, agentId, includeSequence) => this.directorySync.sequenceAgentUpdate( payload, @@ -978,7 +999,15 @@ export class Session { archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId), findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd), listActiveWorkspaces: () => this.listActiveWorkspaceRefs(), - archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), + listWorkspaceRecords: () => this.workspaceRegistry.list(), + archiveWorkspaceRecord: (workspaceId, archiveOptions) => + this.archiveWorkspaceRecord(workspaceId, archiveOptions), + destroyWorkspace: async (workspaceId) => { + if (!this.workspaceRuntime) { + throw new Error(`Workspace runtime is not available: ${workspaceId}`); + } + await this.workspaceRuntime.destroy(workspaceId); + }, emit: (message) => this.emit(message), emitAgentRemove: (agentId) => this.agentUpdates.removeAgent(agentId), emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) => @@ -1003,9 +1032,10 @@ export class Session { serviceProxy: this.serviceProxy, scriptRuntimeStore: this.scriptRuntimeStore, terminalManager: this.terminalManager, + workspaceRuntime: this.workspaceRuntime, workspaceRegistry: this.workspaceRegistry, projectRegistry: this.projectRegistry, - workspaceGitService: this.workspaceGitService, + workspaceGitDirectory: this.workspaceGitDirectory, getDaemonTcpPort: this.getDaemonTcpPort, getDaemonTcpHost: this.getDaemonTcpHost, serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl, @@ -1478,8 +1508,15 @@ export class Session { mutation.kind === "remove" || mutation.workspace?.archivedAt ) { + this.workspacesAwaitingInitialAgent.delete(mutation.workspaceId); this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId); } else { + if (mutation.expectsInitialAgent) { + this.workspacesAwaitingInitialAgent.add(mutation.workspaceId); + } + if (mutation.provisional) { + return; + } await this.syncWorkspaceMutationObserver(mutation); } if (this.isCleanedUp) { @@ -1487,7 +1524,9 @@ export class Session { } await this.emitWorkspaceUpdatesForWorkspaceIds( [mutation.workspaceId], - mutation.expectsInitialAgent ? { optimisticStatus: "running" } : undefined, + this.workspacesAwaitingInitialAgent.has(mutation.workspaceId) + ? { optimisticStatus: "running" } + : undefined, ); } catch (error) { this.sessionLogger.warn( @@ -1766,7 +1805,7 @@ export class Session { if (!project) { throw new Error(`Project not found for workspace ${workspace.workspaceId}`); } - const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = this.workspaceGitDirectory.bindRecord(workspace).peekSnapshot(); const checkout = checkoutFromPersistedWorkspacePlacement({ workspace, // COMPAT(workspacePlacementBackfill): added in v0.1.107, remove after 2027-01-15. @@ -1879,6 +1918,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) ?? @@ -2321,6 +2361,17 @@ export class Session { } } + private dispatchWorkspaceRuntimeMessage(msg: SessionInboundMessage): Promise | undefined { + switch (msg.type) { + case "workspace.runtime.list.request": + return this.handleWorkspaceRuntimeListRequest(msg.requestId); + case "workspace.runtime.ensure_probe.request": + return this.handleWorkspaceRuntimeEnsureProbeRequest(msg); + default: + return undefined; + } + } + private dispatchWorkspaceFileMessage( msg: SessionInboundMessage, source?: object, @@ -2331,8 +2382,7 @@ export class Session { case "fs.file.subscribe.request": return this.workspaceFilesSession.handleFileSubscribeRequest(msg); case "fs.file.unsubscribe.request": - this.workspaceFilesSession.handleFileUnsubscribeRequest(msg); - return undefined; + return this.workspaceFilesSession.handleFileUnsubscribeRequest(msg); case "fs.file.write.request": return this.workspaceFilesSession.handleFileWriteRequest(msg); case "fs.entry.create.request": @@ -2966,20 +3016,33 @@ export class Session { } const removedWorkspaceIds: string[] = []; + const destroyRuntimeWorkspace = async (workspaceId: string): Promise => { + if (!this.workspaceRuntime) { + throw new Error(`Workspace runtime is not available: ${workspaceId}`); + } + await this.workspaceRuntime.destroy(workspaceId); + }; try { - for (const workspaceId of activeWorkspaceIds) { - await archiveWorkspaceContents( - { - agentManager: this.agentManager, - agentStorage: this.agentStorage, - killTerminalsForWorkspace: (id) => - this.terminalController.killTerminalsForWorkspace(id), - sessionLogger: this.sessionLogger, - }, - workspaceId, - ); - await this.archiveWorkspaceRecord(workspaceId); - removedWorkspaceIds.push(workspaceId); + for (const workspace of projectWorkspaces) { + if (!workspace.archivedAt) { + await archiveWorkspaceContents( + { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + killTerminalsForWorkspace: (id) => + this.terminalController.killTerminalsForWorkspace(id), + sessionLogger: this.sessionLogger, + }, + workspace.workspaceId, + ); + } + if (workspace.runtime) { + await destroyRuntimeWorkspace(workspace.workspaceId); + removedWorkspaceIds.push(workspace.workspaceId); + } else if (!workspace.archivedAt) { + await this.archiveWorkspaceRecord(workspace.workspaceId); + removedWorkspaceIds.push(workspace.workspaceId); + } } await this.projectRegistry.remove(resolvedProjectId); @@ -3284,6 +3347,7 @@ export class Session { let createdWorktreeForCleanup: CreatePaseoWorktreeWorkflowResult | null = null; let createdAgentId: string | null = null; + let initialAgentWorkspaceId: string | null = null; try { const requestedCwd = resolve(config.cwd); const needsRequestedDirectory = @@ -3314,10 +3378,8 @@ export class Session { createdWorktree, workspacePromptTitle, }); - const resolvedCwd = resolve(resolvedIntent.config.cwd); - if (!(await this.filesystem.isDirectory(resolvedCwd))) { - throw new Error(`Working directory does not exist or is not a directory: ${resolvedCwd}`); - } + initialAgentWorkspaceId = resolvedIntent.intent.workspaceId; + await this.requireCreateAgentWorkspaceReady(resolvedIntent); const { snapshot, liveSnapshot } = await createAgentCommand( { @@ -3386,6 +3448,10 @@ export class Session { createdWorktree: createdWorktreeForCleanup, createdAgentId, }); + if (initialAgentWorkspaceId) { + this.workspacesAwaitingInitialAgent.delete(initialAgentWorkspaceId); + await this.emitWorkspaceUpdateForWorkspaceId(initialAgentWorkspaceId); + } const wireError = toWorktreeWireError(error); this.sessionLogger.error({ err: error }, "Failed to create agent"); if (requestId) { @@ -3411,6 +3477,26 @@ export class Session { } } + private async requireCreateAgentWorkspaceReady( + resolvedIntent: ResolvedSessionCreateAgentIntent, + ): Promise { + const workspace = await this.workspaceRegistry.get(resolvedIntent.intent.workspaceId); + if (!workspace?.runtime) { + const cwd = resolve(resolvedIntent.config.cwd); + if (!(await this.filesystem.isDirectory(cwd))) { + throw new Error(`Working directory does not exist or is not a directory: ${cwd}`); + } + return; + } + if (!this.workspaceRuntime) { + throw new Error(`Workspace runtime is not available: ${workspace.workspaceId}`); + } + const inspection = await this.workspaceRuntime.inspect(workspace.workspaceId); + if (inspection.status !== "ready") { + throw new Error(`Workspace runtime is ${inspection.status}: ${workspace.workspaceId}`); + } + } + private async resolveSessionCreateAgentIntent(input: { request: CreateAgentRequestMessage; createdWorktree: CreatePaseoWorktreeWorkflowResult | null; @@ -4113,7 +4199,9 @@ export class Session { agentStorage: this.agentStorage, findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd), listActiveWorkspaces: () => this.listActiveWorkspaceRefs(), - archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), + listWorkspaceRecords: () => this.workspaceRegistry.list(), + archiveWorkspaceRecord: (workspaceId, options) => + this.archiveWorkspaceRecord(workspaceId, options), emit: (message) => this.emit(message), emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) => this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds), @@ -4573,7 +4661,7 @@ export class Session { projectRecord ?? (await this.projectRegistry.get(workspace.projectId)); let diffStat: { additions: number; deletions: number } | null = null; - const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = this.workspaceGitDirectory.bindRecord(workspace).peekSnapshot(); if (snapshot?.git.diffStat) { diffStat = snapshot.git.diffStat; } @@ -4593,6 +4681,7 @@ export class Session { projectCustomIconRevision: resolvedProjectRecord?.customIconRevision ?? null, projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd, workspaceDirectory: workspace.cwd, + hostVisiblePath: workspace.runtime ? (workspace.hostVisiblePath ?? undefined) : workspace.cwd, worktreeSlug, projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git", workspaceKind: workspace.kind, @@ -4604,7 +4693,7 @@ export class Session { statusEnteredAt: null, activityAt: null, diffStat, - scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord), + scripts: await this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord), ...(resolvedProjectRecord ? { project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord), @@ -4646,7 +4735,7 @@ export class Session { projectRecord?: PersistedProjectRecord | null, ): Promise { const base = await this.describeWorkspaceRecord(workspace, projectRecord); - const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = this.workspaceGitDirectory.bindRecord(workspace).peekSnapshot(); if (!snapshot) { return base; } @@ -4860,7 +4949,15 @@ export class Session { } private async restoreWorkspaceAndEmit(workspaceId: string): Promise { - await this.workspaceRecovery.restore(workspaceId); + const existing = await this.workspaceRegistry.get(workspaceId); + if (existing?.runtime) { + if (!this.workspaceRuntime) { + throw new Error(`Workspace runtime is not available: ${workspaceId}`); + } + await this.workspaceRuntime.restore(workspaceId); + } else { + await this.workspaceRecovery.restore(workspaceId); + } const workspace = await this.workspaceRegistry.get(workspaceId); if (!workspace) { throw new Error(`Recovered workspace record not found: ${workspaceId}`); @@ -4902,6 +4999,7 @@ export class Session { resolveDefaultBranch?: (repoRoot: string) => Promise; }, ): Promise { + if (!this.workspaceRuntime) throw new Error("Workspace runtime service was not composed"); const result = await createPaseoWorktree(input, { github: this.github, ...(options?.resolveDefaultBranch @@ -4909,6 +5007,8 @@ export class Session { : {}), workspaceGitService: this.workspaceGitService, workspaceProvisioning: this.workspaceProvisioning, + workspaceRuntime: this.workspaceRuntime, + workspaceRegistry: this.workspaceRegistry, }); void Promise.all([ this.gitMutation.notifyGitMutation(input.cwd, "create-worktree"), @@ -4929,20 +5029,36 @@ export class Session { .map((workspace) => ({ workspaceId: workspace.workspaceId, cwd: workspace.cwd, + hostVisiblePath: workspace.hostVisiblePath, kind: workspace.kind, worktreeRoot: workspace.worktreeRoot, isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree, mainRepoRoot: workspace.mainRepoRoot, + runtimeId: workspace.runtime?.runtimeId ?? null, })); } - private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise { - const archiveTimestamp = archivedAt ?? new Date().toISOString(); - const existingWorkspace = await archivePersistedWorkspaceRecord({ - workspaceId, - archivedAt: archiveTimestamp, - workspaceRegistry: this.workspaceRegistry, - }); + private async archiveWorkspaceRecord( + workspaceId: string, + options?: { archivedAt?: string; releaseBacking?: boolean }, + ): Promise { + const archiveTimestamp = options?.archivedAt ?? new Date().toISOString(); + const currentWorkspace = await this.workspaceRegistry.get(workspaceId); + const existingWorkspace = currentWorkspace?.runtime + ? await (async () => { + if (!this.workspaceRuntime) { + throw new Error(`Workspace runtime is not available: ${workspaceId}`); + } + await this.workspaceRuntime.archive(workspaceId, { + releaseBacking: options?.releaseBacking, + }); + return currentWorkspace; + })() + : await archivePersistedWorkspaceRecord({ + workspaceId, + archivedAt: archiveTimestamp, + workspaceRegistry: this.workspaceRegistry, + }); if (!existingWorkspace) { this.workspaceGitObserver.removeForWorkspaceId(workspaceId); return; @@ -5041,7 +5157,7 @@ export class Session { : null; const nextWorkspace = this.applyOptimisticWorkspaceStatus( filteredWorkspace, - options?.optimisticStatus, + this.resolveWorkspaceUpdateOptimisticStatus(workspaceId, options), ); const lastEmitted = subscription.lastEmittedByWorkspaceId.get(workspaceId); if ( @@ -5094,6 +5210,14 @@ export class Session { } } + private resolveWorkspaceUpdateOptimisticStatus( + workspaceId: string, + options: WorkspaceUpdateOptions | undefined, + ): WorkspaceDescriptorPayload["status"] | undefined { + if (options?.optimisticStatus) return options.optimisticStatus; + return this.workspacesAwaitingInitialAgent.has(workspaceId) ? "running" : undefined; + } + private applyOptimisticWorkspaceStatus( workspace: WorkspaceDescriptorPayload | null, optimisticStatus: WorkspaceDescriptorPayload["status"] | undefined, @@ -5539,6 +5663,42 @@ 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 handleWorkspaceRuntimeEnsureProbeRequest( + request: Extract, + ): Promise { + try { + if (!this.providerProbe) throw new Error("Provider probe service was not composed"); + const ensured = await this.providerProbe.ensure(request); + this.emit({ + type: "workspace.runtime.ensure_probe.response", + payload: { + requestId: request.requestId, + workspaceId: ensured.workspaceId, + status: "ready", + error: null, + }, + }); + } catch (error) { + this.emit({ + type: "workspace.runtime.ensure_probe.response", + payload: { + requestId: request.requestId, + workspaceId: null, + status: "error", + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } + private async handleWorkspaceCreateLocal( request: Extract, ): Promise { @@ -5564,14 +5724,51 @@ 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) }, + { expectsInitialAgent: Boolean(request.firstAgentContext), runtimeId }, ); - await this.syncWorkspaceGitObserverForWorkspace(workspace); - const descriptor = await this.describeWorkspaceRecord(workspace); + const workspaceRuntime = this.workspaceRuntime; + if (!workspaceRuntime) { + await this.workspaceRegistry.remove(workspace.workspaceId); + throw new Error("Workspace runtime service was not composed"); + } + const project = await this.projectRegistry.get(workspace.projectId); + if (!project) { + await this.workspaceRegistry.remove(workspace.workspaceId); + throw new Error(`Project not found after workspace creation: ${workspace.projectId}`); + } + let materializedFreshContent = false; + try { + const placement = await workspaceRuntime.create({ + workspaceId: workspace.workspaceId, + runtimeId, + project: { + id: workspace.projectId, + source: projectRuntimeSource(project), + }, + placement: { kind: "existing" }, + }); + materializedFreshContent = placement.materializedFreshContent; + } catch (error) { + await this.workspaceRegistry.remove(workspace.workspaceId); + throw error; + } + const materializedWorkspace = await this.workspaceRegistry.get(workspace.workspaceId); + if (!materializedWorkspace) { + throw new Error(`Workspace not found after runtime creation: ${workspace.workspaceId}`); + } + await this.syncWorkspaceGitObserverForWorkspace(materializedWorkspace); + const descriptor = await this.describeWorkspaceRecord(materializedWorkspace); this.emit({ type: "workspace.create.response", payload: { @@ -5585,14 +5782,52 @@ export class Session { descriptor, request.firstAgentContext ? "running" : undefined, ); - void this.workspaceGitService - .getSnapshot(workspace.cwd, { force: true, includeForge: true, reason: "open_project" }) - .catch((error) => { - this.sessionLogger.warn( - { err: error, cwd: workspace.cwd }, - "Background snapshot refresh failed after workspace.create", - ); - }); + if (materializedFreshContent) { + this.workspaceSetupRuntime.start(workspace.workspaceId, (signal) => + runWorktreeSetupInBackground( + { + emitWorkspaceUpdateForWorkspaceId: (workspaceId) => + this.emitWorkspaceUpdateForWorkspaceId(workspaceId), + cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => { + this.workspaceSetupSnapshots.set(workspaceId, snapshot); + }, + emit: (message) => this.emit(message), + sessionLogger: this.sessionLogger, + terminalManager: this.terminalManager, + archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), + serviceProxy: this.serviceProxy, + scriptRuntimeStore: this.scriptRuntimeStore, + getDaemonTcpPort: this.getDaemonTcpPort, + getDaemonTcpHost: this.getDaemonTcpHost, + serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl, + onScriptsChanged: (workspaceId, workspaceDirectory) => { + this.workspaceScripts.emitStatusUpdate(workspaceId, workspaceDirectory); + }, + bindWorkspaceRuntime: (workspaceId) => workspaceRuntime.bind(workspaceId), + }, + { + requestCwd: materializedWorkspace.cwd, + repoRoot: materializedWorkspace.cwd, + workspaceId: workspace.workspaceId, + worktree: { + branchName: "", + worktreePath: materializedWorkspace.cwd, + }, + shouldBootstrap: true, + slug: workspace.workspaceId, + worktreePath: materializedWorkspace.cwd, + workspaceCwd: materializedWorkspace.cwd, + }, + signal, + ), + ); + } + void this.warmWorkspaceGitDataForWorkspace(materializedWorkspace).catch((error) => { + this.sessionLogger.warn( + { err: error, workspaceId: materializedWorkspace.workspaceId }, + "Background snapshot refresh failed after workspace.create", + ); + }); if (request.firstAgentContext) { const firstAgentContext = request.firstAgentContext; this.workspaceAutoName.scheduleForDirectory( @@ -5613,6 +5848,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) { @@ -6023,11 +6262,11 @@ export class Session { // Named accessor: the workspace descriptor builder and the git-watch test both read a workspace's // scripts snapshot through here; the workspace-scripts module owns the payload assembly. - private buildWorkspaceScriptPayloadSnapshot( + private async buildWorkspaceScriptPayloadSnapshot( workspace: PersistedWorkspaceRecord, project: PersistedProjectRecord | null, - ): WorkspaceDescriptorPayload["scripts"] { - return this.workspaceScripts.buildSnapshot(workspace, project); + ): Promise { + return await this.workspaceScripts.buildSnapshot(workspace, project); } private handleStartWorkspaceScriptRequest(request: StartWorkspaceScriptRequest): Promise { @@ -6198,6 +6437,10 @@ export class Session { onScriptsChanged: (workspaceId, workspaceDirectory) => { this.workspaceScripts.emitStatusUpdate(workspaceId, workspaceDirectory); }, + bindWorkspaceRuntime: async (workspaceId) => { + if (!this.workspaceRuntime) throw new Error("Workspace runtime service was not composed"); + return this.workspaceRuntime.bind(workspaceId); + }, }, input, options, @@ -6236,7 +6479,9 @@ export class Session { findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd), getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId), listActiveWorkspaces: () => this.listActiveWorkspaceRefs(), - archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), + listWorkspaceRecords: () => this.workspaceRegistry.list(), + archiveWorkspaceRecord: (workspaceId, options) => + this.archiveWorkspaceRecord(workspaceId, options), emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) => this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds), markWorkspaceArchiving: (workspaceIds, archivingAt) => @@ -6250,6 +6495,7 @@ export class Session { { scope: { kind: "workspace", workspaceId: existing.workspaceId }, requestId: request.requestId, + releaseBacking: true, }, ); @@ -7173,7 +7419,7 @@ export class Session { this.checkoutSession.cleanup(); this.workspaceGitObserver.dispose(); - this.workspaceFilesSession.dispose(); + await this.workspaceFilesSession.dispose(); } } diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index f3ae292f39..d17586223b 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -18,8 +18,12 @@ import { createPersistedProjectRecord, createPersistedWorkspaceRecord, } from "./workspace-registry.js"; -import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; +import { + bindWorkspaceGitService, + createNoopWorkspaceGitService, +} from "./test-utils/workspace-git-service-stub.js"; import type { WorkspaceGitObserverService } from "./session/workspace-git-observer/workspace-git-observer-service.js"; +import type { WorkspaceGitDirectory } from "./workspace-git-directory.js"; interface SessionInternals { workspaceUpdatesSubscription: { @@ -31,6 +35,7 @@ interface SessionInternals { }; buildWorkspaceDescriptorMap: () => Promise>; workspaceGitObserver: WorkspaceGitObserverService; + workspaceGitDirectory: WorkspaceGitDirectory; listAgentPayloads: () => Promise; } @@ -38,7 +43,9 @@ interface SessionInternals { // integration tests drive it with a minimal git descriptor for one workspace, then push // snapshots through the captured WorkspaceGitService listener. function syncGitObserver(session: Session, cwd: string, workspaceId: string): void { - asInternals(session).workspaceGitObserver.syncObservers([ + const internals = asInternals(session); + internals.workspaceGitDirectory.bindRecord({ workspaceId, cwd, runtime: undefined }); + internals.workspaceGitObserver.syncObservers([ { id: workspaceId, workspaceDirectory: cwd, @@ -66,7 +73,7 @@ function getWorkspaceUpdates( } const REPO_CWD = path.resolve("/tmp/repo"); -const REPO_SUBSCRIPTION_REQUEST_ID = `subscription:${REPO_CWD}`; +const REPO_SUBSCRIPTION_REQUEST_ID = "subscription:ws-10"; function createWorkspaceRuntimeSnapshot( cwd: string, @@ -188,6 +195,9 @@ function createSessionForWorkspaceGitWatchTests(options?: { scheduleRefreshForCwd: vi.fn(), dispose: vi.fn(), }; + workspaceGitService.bindWorkspace = ({ cwd }) => + bindWorkspaceGitService(workspaceGitService, cwd); + workspaceGitService.bindLegacy = (cwd) => bindWorkspaceGitService(workspaceGitService, cwd); const session = new Session({ clientId: "test-client", diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index d72e0b7993..607e6451ef 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -18,6 +18,15 @@ import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { createTestLogger } from "../test-utils/test-logger.js"; import { Session } from "./session.js"; import type { SessionOptions } from "./session.js"; +import { + createWorkspaceRuntimeService, + type CreateWorkspaceInput, + type WorkspaceRuntimeService, +} from "./workspace-runtime/index.js"; +import { + bindProviderWorkspace, + resolveProviderPlacementPolicy, +} from "./agent/providers/workspace/index.js"; import type { AgentUpdatesService } from "./session/agent-updates/agent-updates-service.js"; import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import type { TerminalManager } from "../terminal/terminal-manager.js"; @@ -78,6 +87,13 @@ const REPO_CWD = path.resolve("/tmp/repo"); const UNREGISTERED_CWD = path.resolve("/tmp/unregistered"); const terminalManagers: TerminalManager[] = []; +const runtimeDirectories: string[] = []; + +function createRuntimeDirectory(): string { + const directory = mkdtempSync(path.join(tmpdir(), "paseo-session-runtime-")); + runtimeDirectories.push(directory); + return directory; +} function deferred(): { promise: Promise; resolve: (value: T) => void } { let resolve!: (value: T) => void; @@ -95,13 +111,17 @@ afterEach(async () => { while (terminalManagers.length > 0) { const manager = terminalManagers.pop(); if (manager) { - manager.killAll(); + await manager.killAll(); } } await flushTerminalContributionWork(); + for (const directory of runtimeDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } }); interface SessionTestAccess { + workspaceRuntime: WorkspaceRuntimeService; projectRegistry: { list(...args: unknown[]): Promise; archive(projectId: string, archivedAt: string): Promise; @@ -140,6 +160,9 @@ interface SessionTestAccess { ): Promise; upsert(record: unknown): Promise; }; + workspaceGitDirectory: { + bindRecord(record: PersistedWorkspaceRecord): unknown; + }; agentUpdates: AgentUpdatesService; workspaceUpdatesSubscription: unknown; interruptAgentIfRunning(agentId: string): unknown; @@ -627,8 +650,11 @@ function createSessionForWorkspaceTests( archive: async () => {}, remove: async () => {}, }; - const workspaceGitService = options.workspaceGitService ?? createNoopWorkspaceGitService(); + const workspaceGitService = options.renameCurrentBranch + ? createNoopWorkspaceGitService({ renameBranch: options.renameCurrentBranch }) + : (options.workspaceGitService ?? createNoopWorkspaceGitService()); const providerSnapshotManager = createProviderSnapshotManagerStub().manager; + const defaultProjects = new Map(); const session = asTestSession( new Session({ @@ -674,21 +700,44 @@ function createSessionForWorkspaceTests( initialize: async () => {}, existsOnDisk: async () => true, list: async () => [], - get: async () => null, - getOrCreateActiveByRoot: async (input) => - createPersistedProjectRecord({ + get: async (projectId) => defaultProjects.get(projectId) ?? null, + getOrCreateActiveByRoot: async (input) => { + const project = createPersistedProjectRecord({ projectId: "prj_0000000000000000", rootPath: input.rootPath, kind: input.kind, displayName: input.displayName, createdAt: input.timestamp, updatedAt: input.timestamp, - }), + }); + defaultProjects.set(project.projectId, project); + return project; + }, upsert: async () => {}, archive: async () => {}, remove: async () => {}, }, workspaceRegistry, + workspaceRuntime: createWorkspaceRuntimeService({ + paseoHome: options.paseoHome ?? "/tmp/paseo-test", + worktreesRoot: options.worktreesRoot, + resolveRuntimeId: async (workspaceId) => { + const workspace = await workspaceRegistry.get(workspaceId); + return workspace?.runtime?.runtimeId ?? null; + }, + persistRuntimeId: async (workspaceId, runtimeId, placement) => { + const workspace = await workspaceRegistry.get(workspaceId); + if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`); + await workspaceRegistry.upsert({ + ...workspace, + cwd: placement.cwd, + hostVisiblePath: placement.hostVisiblePath ?? null, + runtime: { runtimeId }, + }); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: (workspaceId) => workspaceRegistry.remove(workspaceId), + }), filesystem: { isDirectory: async () => true }, scheduleService: asScheduleService(), checkoutDiffManager: asCheckoutDiffManager({ @@ -696,7 +745,12 @@ function createSessionForWorkspaceTests( initial: { cwd: "/tmp", files: [], error: null }, unsubscribe: () => {}, }), + subscribeWorkspace: async ({ workspaceGit }) => ({ + initial: { cwd: workspaceGit.cwd, files: [], error: null }, + unsubscribe: () => {}, + }), scheduleRefreshForCwd: () => {}, + scheduleRefreshForWorkspace: () => {}, onWorkspaceStateMayHaveChanged: () => {}, invalidateForge: () => {}, getMetrics: () => ({ @@ -721,7 +775,6 @@ function createSessionForWorkspaceTests( logger: asSessionLogger(logger), generateWorkspaceName: options.generateWorkspaceName, }), - renameCurrentBranch: options.renameCurrentBranch, daemonConfigStore: asDaemonConfigStore({ get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }), onChange: () => () => {}, @@ -937,17 +990,22 @@ test("create_agent_request keeps requested child cwd when grouped under an exist error: vi.fn(), }; const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger)); + let workspaceRegistry!: FileBackedWorkspaceRegistry; const agentManager = new AgentManager({ clients: { codex: new CreateAgentTestClient() }, registry: agentStorage, logger: asSessionLogger(logger), idFactory: () => "00000000-0000-4000-8000-000000000551", + resolveProviderWorkspace: async (workspaceId) => { + const workspace = await workspaceRegistry.get(workspaceId); + return workspace?.runtime ? undefined : null; + }, }); const projectRegistry = new FileBackedProjectRegistry( path.join(workdir, "projects.json"), asSessionLogger(logger), ); - const workspaceRegistry = new FileBackedWorkspaceRegistry( + workspaceRegistry = new FileBackedWorkspaceRegistry( path.join(workdir, "workspaces.json"), asSessionLogger(logger), ); @@ -1067,6 +1125,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist test("create_agent_request launches from an exact subdirectory in a created worktree", async () => { const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-worktree-cwd-")); + let cleanupRuntime: (() => Promise) | null = null; try { const parent = path.join(workdir, "parent"); const child = path.join(parent, "packages", "app"); @@ -1090,12 +1149,6 @@ test("create_agent_request launches from an exact subdirectory in a created work error: vi.fn(), }; const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger)); - const agentManager = new AgentManager({ - clients: { codex: new CreateAgentTestClient() }, - registry: agentStorage, - logger: asSessionLogger(logger), - idFactory: () => "00000000-0000-4000-8000-000000000552", - }); const projectRegistry = new FileBackedProjectRegistry( path.join(workdir, "projects.json"), asSessionLogger(logger), @@ -1104,6 +1157,44 @@ test("create_agent_request launches from an exact subdirectory in a created work path.join(workdir, "workspaces.json"), asSessionLogger(logger), ); + const workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome: path.join(workdir, "paseo-home"), + worktreesRoot: path.join(workdir, "worktrees"), + resolveRuntimeId: async (workspaceId) => + (await workspaceRegistry.get(workspaceId))?.runtime?.runtimeId ?? null, + persistRuntimeId: async (workspaceId, runtimeId, placement) => { + const updated = await workspaceRegistry.update(workspaceId, (workspace) => ({ + ...workspace, + cwd: placement.cwd, + hostVisiblePath: placement.hostVisiblePath ?? null, + runtime: { runtimeId }, + })); + if (!updated) throw new Error(`Workspace not found: ${workspaceId}`); + }, + beginWorkspaceDeletion: async (workspaceId) => { + await workspaceRegistry.requestDeletion(workspaceId, new Date().toISOString()); + }, + removeWorkspaceRecord: (workspaceId) => workspaceRegistry.remove(workspaceId), + }); + const agentManager = new AgentManager({ + clients: { codex: new CreateAgentTestClient() }, + registry: agentStorage, + logger: asSessionLogger(logger), + idFactory: () => "00000000-0000-4000-8000-000000000552", + resolveProviderWorkspace: async (workspaceId) => { + const workspace = await workspaceRegistry.get(workspaceId); + if (!workspace?.runtime) return null; + const runtime = await workspaceRuntime.bind(workspaceId); + return bindProviderWorkspace({ + runtime, + cwd: ".", + policy: resolveProviderPlacementPolicy({ + capability: runtime.provider, + hostEnvironment: process.env, + }), + }); + }, + }); const workspaceGitService = createNoopWorkspaceGitService({ getCheckout: async (cwd: string) => ({ cwd, @@ -1153,6 +1244,7 @@ test("create_agent_request launches from an exact subdirectory in a created work agentStorage, projectRegistry, workspaceRegistry, + workspaceRuntime, scheduleService: asScheduleService(), checkoutDiffManager: asCheckoutDiffManager({ subscribe: async () => ({ @@ -1191,6 +1283,12 @@ test("create_agent_request launches from an exact subdirectory in a created work providerSnapshotManager: createProviderSnapshotManagerStub().manager, terminalManager: null, }); + cleanupRuntime = async () => { + await session.cleanup(); + for (const workspace of await workspaceRegistry.list()) { + if (workspace.runtime) await workspaceRuntime.destroy(workspace.workspaceId); + } + }; await session.handleMessage({ type: "create_agent_request", @@ -1199,6 +1297,16 @@ test("create_agent_request launches from an exact subdirectory in a created work attachments: [], worktree: { mode: "branch-off", newBranch: "feature/created-worktree" }, }); + await vi.waitFor( + () => + expect( + emitted.some( + (message) => + message.type === "workspace_setup_progress" && message.payload.status === "completed", + ), + ).toBe(true), + { timeout: 10_000 }, + ); const [createdAgent] = agentManager.listAgents(); const createdWorktreeRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { @@ -1217,6 +1325,7 @@ test("create_agent_request launches from an exact subdirectory in a created work agent: { cwd: createdAgent?.cwd }, }); } finally { + await cleanupRuntime?.(); rmSync(workdir, { recursive: true, force: true }); } }); @@ -1242,6 +1351,7 @@ test("create_agent_request does not title an existing workspace from the agent p registry: agentStorage, logger: asSessionLogger(logger), idFactory: () => "00000000-0000-4000-8000-000000000552", + resolveProviderWorkspace: async () => null, }); const projectRegistry = new FileBackedProjectRegistry( path.join(workdir, "projects.json"), @@ -2402,21 +2512,26 @@ test("non-git workspace uses deterministic directory name and no unknown branch test("workspace placements preserve checkout facts independently from the project", async () => { const session = createSessionForWorkspaceTests(); + const mainRepoRoot = path.resolve("/tmp/main-repo"); + const manualWorktreeCwd = path.resolve("/tmp/manual-worktree"); + const explicitDirectoryCwd = path.resolve("/tmp/plain-directory"); + const paseoWorktreeRoot = path.resolve("/tmp/paseo-worktree"); + const paseoSubdirectoryCwd = path.join(paseoWorktreeRoot, "packages", "app"); const manualWorktree = createPersistedWorkspaceRecord({ workspaceId: "ws-manual-worktree", projectId: "proj-manual-worktree", - cwd: "/tmp/manual-worktree", + cwd: manualWorktreeCwd, kind: "worktree", displayName: "manual", isPaseoOwnedWorktree: false, - mainRepoRoot: "/tmp/main-repo", + mainRepoRoot, createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); const explicitDirectory = createPersistedWorkspaceRecord({ workspaceId: "ws-explicit-directory", projectId: "proj-manual-worktree", - cwd: "/tmp/plain-directory", + cwd: explicitDirectoryCwd, kind: "directory", displayName: "plain", createdAt: "2026-03-01T12:00:00.000Z", @@ -2425,17 +2540,17 @@ test("workspace placements preserve checkout facts independently from the projec const paseoSubdirectory = createPersistedWorkspaceRecord({ workspaceId: "ws-paseo-subdirectory", projectId: "proj-manual-worktree", - cwd: "/tmp/paseo-worktree/packages/app", + cwd: paseoSubdirectoryCwd, kind: "worktree", displayName: "app", isPaseoOwnedWorktree: true, - mainRepoRoot: "/tmp/main-repo", + mainRepoRoot, createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); const project = createPersistedProjectRecord({ projectId: "proj-manual-worktree", - rootPath: "/tmp/main-repo", + rootPath: mainRepoRoot, kind: "git", displayName: "main", createdAt: "2026-03-01T12:00:00.000Z", @@ -2449,7 +2564,7 @@ test("workspace placements preserve checkout facts independently from the projec session.workspaceGitService.peekSnapshot = (cwd: string) => cwd === paseoSubdirectory.cwd ? createWorkspaceRuntimeSnapshot(cwd, { - git: { repoRoot: "/tmp/paseo-worktree" }, + git: { repoRoot: paseoWorktreeRoot }, }) : null; @@ -2460,7 +2575,7 @@ test("workspace placements preserve checkout facts independently from the projec checkout: expect.objectContaining({ isGit: true, isPaseoOwnedWorktree: false, - mainRepoRoot: "/tmp/main-repo", + mainRepoRoot, }), }), ); @@ -2481,7 +2596,7 @@ test("workspace placements preserve checkout facts independently from the projec expect.objectContaining({ checkout: expect.objectContaining({ cwd: paseoSubdirectory.cwd, - worktreeRoot: "/tmp/paseo-worktree", + worktreeRoot: paseoWorktreeRoot, }), }), ); @@ -3986,6 +4101,13 @@ test("create paseo worktree response preserves an explicit non-Git project", asy ) => { workspaces.set(record.workspaceId, record); }; + session.workspaceRegistry.update = async (workspaceId, updater) => { + const workspace = workspaces.get(workspaceId); + if (!workspace) return null; + const updated = updater(workspace); + workspaces.set(workspaceId, updated); + return updated; + }; session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; session.projectRegistry.list = async () => Array.from(projects.values()); session.projectRegistry.getOrCreateActiveByRoot = async (input) => { @@ -7191,6 +7313,8 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho const describeWorkspaceRecordWithGitDataSubscribed = vi.fn(async () => enrichedGitDescriptor); session.describeWorkspaceRecord = describeWorkspaceRecordSubscribed; session.describeWorkspaceRecordWithGitData = describeWorkspaceRecordWithGitDataSubscribed; + session.workspaceGitDirectory.bindRecord(gitWorkspace); + session.workspaceGitDirectory.bindRecord(directoryWorkspace); await session.handleMessage({ type: "fetch_workspaces_request", @@ -7759,6 +7883,17 @@ test("queued workspace updates are dropped when a new workspace subscription rep } return new Map([[currentDescriptor.id, currentDescriptor]]); }; + session.workspaceGitDirectory.bindRecord( + createPersistedWorkspaceRecord({ + workspaceId: descriptor.id, + projectId: descriptor.projectId, + cwd: descriptor.workspaceDirectory, + kind: descriptor.workspaceKind, + displayName: descriptor.name, + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ); await session.handleMessage({ type: "fetch_workspaces_request", @@ -8909,6 +9044,13 @@ test("workspace.create worktree source checks out a GitHub PR from githubPrNumbe upsert: async (record) => { workspaces.set(record.workspaceId, record); }, + update: async (workspaceId, updater) => { + const workspace = workspaces.get(workspaceId); + if (!workspace) return null; + const updated = updater(workspace); + workspaces.set(workspaceId, updated); + return updated; + }, archive: async () => {}, remove: async () => {}, }; @@ -8946,6 +9088,12 @@ test("workspace.create worktree source checks out a GitHub PR from githubPrNumbe gitRuntime: { currentBranch: fixture.headRef }, }); const workspaceDirectory = response?.payload.workspace?.workspaceDirectory as string; + const persistedWorkspace = workspaces.get(response?.payload.workspace?.id ?? ""); + expect(persistedWorkspace).toMatchObject({ + cwd: workspaceDirectory, + hostVisiblePath: workspaceDirectory, + runtime: { runtimeId: "worktree" }, + }); expect(readCurrentBranch(workspaceDirectory)).toBe(fixture.headRef); expect(existsSync(path.join(workspaceDirectory, fixture.prFileName))).toBe(true); } finally { @@ -9260,6 +9408,7 @@ test("checkout.rename_branch.request renames the branch without a denormalized b }); test("workspace.create.response persists the first prompt as the initial title", async () => { + const runtimeCwd = createRuntimeDirectory(); const emitted: SessionOutboundMessage[] = []; const workspaces = new Map>(); const session = createSessionForWorkspaceTests({ @@ -9281,7 +9430,7 @@ test("workspace.create.response persists the first prompt as the initial title", await session.handleMessage({ type: "workspace.create.request", requestId: "req-create-first-prompt", - source: { kind: "directory", path: REPO_CWD }, + source: { kind: "directory", path: runtimeCwd }, firstAgentContext: { prompt: "Add retries to the payments flow\nwith exponential backoff", }, @@ -9300,6 +9449,7 @@ test("workspace.create.response persists the first prompt as the initial title", }); test("workspace create emits through a matching workspace subscription", async () => { + const runtimeCwd = createRuntimeDirectory(); const emitted: SessionOutboundMessage[] = []; const workspaces = new Map>(); let mutationListener: ((mutation: WorkspaceMutation) => void | Promise) | null = null; @@ -9341,18 +9491,46 @@ test("workspace create emits through a matching workspace subscription", async ( await session.handleMessage({ type: "workspace.create.request", requestId: "req-create-match", - source: { kind: "directory", path: REPO_CWD }, + source: { kind: "directory", path: runtimeCwd }, firstAgentContext: { prompt: "Implement the requested change" }, }); + const createdWorkspace = Array.from(workspaces.values())[0]; + expect(createdWorkspace).toBeDefined(); + await mutationListener?.({ + kind: "upsert", + workspaceId: createdWorkspace.workspaceId, + workspace: createdWorkspace, + }); + const statuses = filterByType(emitted, "workspace_update").flatMap((message) => message.payload.kind === "upsert" ? [message.payload.workspace.status] : [], ); expect(statuses).toContain("running"); expect(statuses).not.toContain("done"); + + emitted.length = 0; + await session.handleMessage({ + type: "create_agent_request", + requestId: "req-create-match-agent-failure", + workspaceId: createdWorkspace.workspaceId, + config: { provider: "unknown-provider", cwd: createdWorkspace.cwd }, + initialPrompt: "This agent cannot be created.", + attachments: [], + }); + expect(findByType(emitted, "status")?.payload).toMatchObject({ + status: "agent_create_failed", + requestId: "req-create-match-agent-failure", + }); + const failureStatuses = filterByType(emitted, "workspace_update").flatMap((message) => + message.payload.kind === "upsert" ? [message.payload.workspace.status] : [], + ); + expect(failureStatuses).toContain("done"); + expect(failureStatuses).not.toContain("running"); }); test("workspace create stays out of a non-matching workspace subscription", async () => { + const runtimeCwd = createRuntimeDirectory(); const emitted: SessionOutboundMessage[] = []; const workspaces = new Map>(); const session = createSessionForWorkspaceTests({ @@ -9381,20 +9559,23 @@ test("workspace create stays out of a non-matching workspace subscription", asyn await session.handleMessage({ type: "workspace.create.request", requestId: "req-create-filtered", - source: { kind: "directory", path: REPO_CWD }, + source: { kind: "directory", path: runtimeCwd }, }); expect(filterByType(emitted, "workspace_update")).toEqual([]); }); test("workspace.create.request attaches a directory workspace to its explicit active project", async () => { + const runtimeCwd = createRuntimeDirectory(); + const projectRoot = path.join(runtimeCwd, "unrelated"); + mkdirSync(projectRoot); const emitted: SessionOutboundMessage[] = []; const projects = new Map([ [ "prj_explicit", createPersistedProjectRecord({ projectId: "prj_explicit", - rootPath: path.join(REPO_CWD, "unrelated"), + rootPath: projectRoot, kind: "non_git", displayName: "unrelated", createdAt: "2026-03-01T00:00:00.000Z", @@ -9415,7 +9596,7 @@ test("workspace.create.request attaches a directory workspace to its explicit ac await session.handleMessage({ type: "workspace.create.request", requestId: "req-explicit-project", - source: { kind: "directory", path: REPO_CWD, projectId: "prj_explicit" }, + source: { kind: "directory", path: runtimeCwd, projectId: "prj_explicit" }, }); const response = findByType(emitted, "workspace.create.response"); @@ -9427,11 +9608,72 @@ test("workspace.create.request attaches a directory workspace to its explicit ac const workspaceId = response?.payload.workspace?.id; expect(workspaceId).toEqual(expect.any(String)); expect(workspaces.get(workspaceId as string)).toMatchObject({ - cwd: REPO_CWD, + cwd: projectRoot, projectId: "prj_explicit", }); }); +test("workspace.create.request materializes an explicit project's git source instead of its cwd", async () => { + const runtimeCwd = createRuntimeDirectory(); + const emitted: SessionOutboundMessage[] = []; + const project = createPersistedProjectRecord({ + projectId: "prj_git_source", + rootPath: path.join(runtimeCwd, "host-decoy"), + source: { + kind: "git", + url: "https://example.test/acme/project.git", + revision: "release", + subdirectory: "packages/app", + }, + kind: "git", + displayName: "project", + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }); + const workspaces = new Map(); + const creates: CreateWorkspaceInput[] = []; + const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); + session.projectRegistry.get = async (projectId: string) => + projectId === project.projectId ? project : null; + session.workspaceRegistry.upsert = async (record: unknown) => { + const workspace = record as PersistedWorkspaceRecord; + workspaces.set(workspace.workspaceId, workspace); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRuntime.create = async (input) => { + creates.push(input); + const workspace = workspaces.get(input.workspaceId); + if (!workspace) throw new Error(`Workspace not found: ${input.workspaceId}`); + await session.workspaceRegistry.upsert({ + ...workspace, + runtime: { runtimeId: input.runtimeId }, + }); + return { + workspaceId: input.workspaceId, + runtimeId: input.runtimeId, + cwd: runtimeCwd, + materializedFreshContent: false, + }; + }; + + await session.handleMessage({ + type: "workspace.create.request", + requestId: "req-git-source-project", + source: { kind: "directory", path: runtimeCwd, projectId: project.projectId }, + }); + + expect(findByType(emitted, "workspace.create.response")?.payload.error).toBeNull(); + expect(creates).toHaveLength(1); + expect(creates[0]?.project.source).toEqual({ + kind: "git", + url: "https://example.test/acme/project.git", + revision: "release", + subdirectory: "packages/app", + }); + expect(JSON.stringify(creates[0]?.project.source)).not.toContain(runtimeCwd); +}); + test("workspace.create.request reports an unknown explicit project", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); diff --git a/packages/server/src/server/session/checkout/checkout-session.test.ts b/packages/server/src/server/session/checkout/checkout-session.test.ts index d29418a9a5..bbfb9e199b 100644 --- a/packages/server/src/server/session/checkout/checkout-session.test.ts +++ b/packages/server/src/server/session/checkout/checkout-session.test.ts @@ -23,10 +23,12 @@ import type { WorkspaceGitService, } from "../../workspace-git-service.js"; import { + bindWorkspaceGitService, createNoGitWorkspaceRuntimeSnapshot, createNoopWorkspaceGitService, } from "../../test-utils/workspace-git-service-stub.js"; import { expandTilde } from "../../../utils/path.js"; +import { discardChanges } from "../../../utils/checkout-git.js"; import type { GitMetadataGenerator } from "./git-metadata-generator.js"; function isCheckDetailsResponse(msg: SessionOutboundMessage): boolean { @@ -48,10 +50,10 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) { const subscriptions: FakeDiffSubscription[] = []; const refreshedCwds: string[] = []; const subscriber: CheckoutDiffSubscriber = { - subscribe: async (params, listener) => { + subscribeWorkspace: async (params, listener) => { let isSubscribed = true; const subscription: FakeDiffSubscription = { - cwd: params.cwd, + cwd: params.workspaceGit.cwd, compare: params.compare, unsubscribeCalls: 0, emit: (snapshot) => { @@ -70,12 +72,12 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) { params.signal?.addEventListener("abort", unsubscribe, { once: true }); subscriptions.push(subscription); return { - initial: { ...initial, cwd: params.cwd }, + initial: { ...initial, cwd: params.workspaceGit.cwd }, unsubscribe, }; }, - scheduleRefreshForCwd: (cwd) => { - refreshedCwds.push(cwd); + scheduleRefreshForWorkspace: (workspaceGit) => { + refreshedCwds.push(workspaceGit.cwd); }, }; return { subscriber, subscriptions, refreshedCwds }; @@ -83,7 +85,12 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) { interface RecordedHostCalls { emitWorkspaceUpdateForCwd: string[]; - handleWorkspaceGitBranchSnapshot: Array<{ cwd: string; branchName: string | null }>; + handleWorkspaceGitBranchSnapshot: Array<{ + address: + | { kind: "selected"; cwd: string; workspaceId: string } + | { kind: "legacy"; cwd: string }; + branchName: string | null; + }>; renameCurrentBranch: Array<{ cwd: string; branch: string }>; } @@ -130,12 +137,8 @@ function makeCheckoutSession(options?: { emitWorkspaceUpdateForCwd: async (cwd) => { hostCalls.emitWorkspaceUpdateForCwd.push(cwd); }, - handleWorkspaceGitBranchSnapshot: (cwd, branchName) => { - hostCalls.handleWorkspaceGitBranchSnapshot.push({ cwd, branchName }); - }, - renameCurrentBranch: async (cwd, branch) => { - hostCalls.renameCurrentBranch.push({ cwd, branch }); - return { previousBranch: null, currentBranch: branch }; + handleWorkspaceGitBranchSnapshot: (address, branchName) => { + hostCalls.handleWorkspaceGitBranchSnapshot.push({ address, branchName }); }, ...options?.host, }; @@ -161,16 +164,33 @@ function makeCheckoutSession(options?: { ...options?.gitMetadataGenerator, }; const github: ForgeService = { ...createGitHubService(), ...options?.github }; + const workspaceGitService = createNoopWorkspaceGitService({ + renameBranch: async (cwd, branch) => { + hostCalls.renameCurrentBranch.push({ cwd, branch }); + return { previousBranch: null, currentBranch: branch }; + }, + ...options?.git, + }); const checkout = new CheckoutSession({ host, - gitMutation, - workspaceGitService: createNoopWorkspaceGitService(options?.git), + gitMutation: { + bind: (workspaceGit) => ({ + checkoutExistingBranch: (branch) => + gitMutation.checkoutExistingBranch(workspaceGit.cwd, branch), + createBranchFromBase: async () => {}, + notify: (reason, notifyOptions) => + gitMutation.notifyGitMutation(workspaceGit.cwd, reason, notifyOptions), + }), + }, + workspaceGitDirectory: { + resolve: async ({ cwd }) => bindWorkspaceGitService(workspaceGitService, cwd), + bindRecord: (record) => bindWorkspaceGitService(workspaceGitService, record.cwd), + getBound: (_workspaceId, cwd) => bindWorkspaceGitService(workspaceGitService, cwd), + }, github, checkoutDiffManager: options?.diff ?? createFakeDiffSubscriber({ cwd: "", files: [], error: null }).subscriber, gitMetadataGenerator, - paseoHome: "/tmp/paseo-home", - worktreesRoot: undefined, logger: pino({ level: "silent" }), }); return { checkout, emitted, hostCalls, gitMutationCalls, generatorCalls }; @@ -379,8 +399,8 @@ describe("CheckoutSession", () => { }); describe("refresh", () => { - it("forces a github-inclusive snapshot, nudges diffs, and confirms success", async () => { - const snapshotCalls: Array<{ cwd: string; options: unknown }> = []; + it("refreshes the bound workspace, nudges diffs, and confirms success", async () => { + const refreshCalls: Array<{ cwd: string; options: unknown }> = []; const { subscriber, refreshedCwds } = createFakeDiffSubscriber({ cwd: "", files: [], @@ -388,9 +408,8 @@ describe("CheckoutSession", () => { }); const { checkout, emitted } = makeCheckoutSession({ git: { - getSnapshot: async (cwd, snapshotOptions) => { - snapshotCalls.push({ cwd, options: snapshotOptions }); - return createNoGitWorkspaceRuntimeSnapshot(cwd); + refresh: async (cwd, refreshOptions) => { + refreshCalls.push({ cwd, options: refreshOptions }); }, }, diff: subscriber, @@ -402,9 +421,7 @@ describe("CheckoutSession", () => { requestId: "r7", }); - expect(snapshotCalls).toEqual([ - { cwd: "/repo", options: { force: true, includeForge: true, reason: "manual-refresh" } }, - ]); + expect(refreshCalls).toEqual([{ cwd: "/repo", options: { priority: "high" } }]); expect(refreshedCwds).toEqual(["/repo"]); expect(emitted).toEqual([ { @@ -415,7 +432,7 @@ describe("CheckoutSession", () => { }); it("expands a tilde cwd before refreshing git and diffs", async () => { - const snapshotCalls: string[] = []; + const refreshCalls: string[] = []; const { subscriber, refreshedCwds } = createFakeDiffSubscriber({ cwd: "", files: [], @@ -423,9 +440,8 @@ describe("CheckoutSession", () => { }); const { checkout } = makeCheckoutSession({ git: { - getSnapshot: async (cwd) => { - snapshotCalls.push(cwd); - return createNoGitWorkspaceRuntimeSnapshot(cwd); + refresh: async (cwd) => { + refreshCalls.push(cwd); }, }, diff: subscriber, @@ -438,7 +454,7 @@ describe("CheckoutSession", () => { }); const resolvedCwd = expandTilde("~/repo"); - expect(snapshotCalls).toEqual([resolvedCwd]); + expect(refreshCalls).toEqual([resolvedCwd]); expect(refreshedCwds).toEqual([resolvedCwd]); }); }); @@ -461,7 +477,10 @@ describe("CheckoutSession", () => { files: [], error: null, }); - const { checkout, emitted, gitMutationCalls } = makeCheckoutSession({ diff: subscriber }); + const { checkout, emitted, gitMutationCalls } = makeCheckoutSession({ + diff: subscriber, + git: { discardChanges }, + }); await checkout.handleCheckoutDiscardChangesRequest({ type: "checkout.discard_changes.request", @@ -497,7 +516,10 @@ describe("CheckoutSession", () => { files: [], error: null, }); - const { checkout, emitted, gitMutationCalls } = makeCheckoutSession({ diff: subscriber }); + const { checkout, emitted, gitMutationCalls } = makeCheckoutSession({ + diff: subscriber, + git: { discardChanges }, + }); await checkout.handleCheckoutDiscardChangesRequest({ type: "checkout.discard_changes.request", @@ -636,12 +658,16 @@ describe("CheckoutSession", () => { it("emits a checkout status update for a workspace git snapshot", () => { const { checkout, emitted } = makeCheckoutSession(); - checkout.emitStatusUpdate("/repo", createGitSnapshot("/repo", "main")); + checkout.emitStatusUpdate("workspace-1", "/repo", createGitSnapshot("/repo", "main")); expect(emitted).toEqual([ { type: "checkout_status_update", - payload: expect.objectContaining({ cwd: "/repo", currentBranch: "main" }), + payload: expect.objectContaining({ + workspaceId: "workspace-1", + cwd: "/repo", + currentBranch: "main", + }), }, ]); }); @@ -650,8 +676,8 @@ describe("CheckoutSession", () => { const { checkout, emitted } = makeCheckoutSession(); const snapshot = createGitSnapshot("/repo", "main"); - checkout.emitStatusUpdate("/repo", snapshot); - checkout.emitStatusUpdate("/repo", snapshot); + checkout.emitStatusUpdate("workspace-1", "/repo", snapshot); + checkout.emitStatusUpdate("workspace-1", "/repo", snapshot); expect(emitted).toHaveLength(1); }); @@ -768,7 +794,7 @@ describe("CheckoutSession", () => { ]); expect(refreshedCwds).toEqual(["/repo"]); expect(hostCalls.handleWorkspaceGitBranchSnapshot).toEqual([ - { cwd: "/repo", branchName: "feature-renamed" }, + { address: { kind: "legacy", cwd: "/repo" }, branchName: "feature-renamed" }, ]); expect(hostCalls.emitWorkspaceUpdateForCwd).toEqual(["/repo"]); expect(emitted).toEqual([ diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 7545b9f510..0769577c78 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -19,7 +19,7 @@ import type { import type { CheckoutDiffSnapshotPayload, CheckoutDiffSubscription, - CheckoutDiffSubscriptionRequest, + CheckoutDiffWorkspaceSubscriptionRequest, } from "../../checkout-diff-manager.js"; import { toCheckoutError } from "../../checkout-git-utils.js"; import { @@ -28,9 +28,11 @@ import { } from "../../checkout/status-projection.js"; import type { WorkspaceGitRuntimeSnapshot, - WorkspaceGitService, WorkspaceGitSnapshotOptions, + WorkspaceGitWorkspace, } from "../../workspace-git-service.js"; +import type { WorkspaceGitDirectory } from "../../workspace-git-directory.js"; +import type { WorkspaceGitAddress } from "../../workspace-git-directory.js"; import { assertSafeGitRef } from "../../worktree-session.js"; import type { GitMutationService } from "../git-mutation/git-mutation-service.js"; import type { @@ -40,19 +42,10 @@ import type { SearchResult, } from "../../../services/forge-service.js"; import { - commitChanges, createPullRequest, - discardChanges, forgeAuthStateFromError, isForgeAuthError, - mergeFromBase, - mergeToBase, - pullCurrentBranch, - pushCurrentBranch, - listCheckoutCommits, - getCommitFileDiff, } from "../../../utils/checkout-git.js"; -import { runGitCommand } from "../../../utils/run-git-command.js"; import { expandTilde } from "../../../utils/path.js"; import type { GitMetadataGenerator } from "./git-metadata-generator.js"; @@ -68,11 +61,7 @@ import type { GitMetadataGenerator } from "./git-metadata-generator.js"; export interface CheckoutSessionHost { emit(msg: SessionOutboundMessage): void; emitWorkspaceUpdateForCwd(cwd: string): Promise; - handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void; - renameCurrentBranch( - cwd: string, - branch: string, - ): Promise<{ previousBranch: string | null; currentBranch: string | null }>; + handleWorkspaceGitBranchSnapshot(address: WorkspaceGitAddress, branchName: string | null): void; } type CurrentWorkspacePullRequest = NonNullable< @@ -108,22 +97,20 @@ function toLegacyGithubSearchItems(items: ForgeSearchResultItem[]): LegacyGithub * real CheckoutDiffManager satisfies this structurally; tests supply a fake. */ export interface CheckoutDiffSubscriber { - subscribe( - params: CheckoutDiffSubscriptionRequest, + subscribeWorkspace( + params: CheckoutDiffWorkspaceSubscriptionRequest, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, ): Promise; - scheduleRefreshForCwd(cwd: string): void; + scheduleRefreshForWorkspace(workspaceGit: WorkspaceGitWorkspace): void; } export interface CheckoutSessionOptions { host: CheckoutSessionHost; - gitMutation: Pick; - workspaceGitService: WorkspaceGitService; + gitMutation: Pick; + workspaceGitDirectory: WorkspaceGitDirectory; github: ForgeService; checkoutDiffManager: CheckoutDiffSubscriber; gitMetadataGenerator: GitMetadataGenerator; - paseoHome: string; - worktreesRoot: string | undefined; logger: pino.Logger; } @@ -141,16 +128,11 @@ export class CheckoutSession { private static readonly PASEO_STASH_PREFIX = "paseo-auto-stash:"; private readonly host: CheckoutSessionHost; - private readonly gitMutation: Pick< - GitMutationService, - "checkoutExistingBranch" | "notifyGitMutation" - >; - private readonly workspaceGitService: WorkspaceGitService; + private readonly gitMutation: Pick; + private readonly workspaceGitDirectory: WorkspaceGitDirectory; private readonly github: ForgeService; private readonly checkoutDiffManager: CheckoutDiffSubscriber; private readonly gitMetadataGenerator: GitMetadataGenerator; - private readonly paseoHome: string; - private readonly worktreesRoot: string | undefined; private readonly logger: pino.Logger; private readonly diffSubscriptions = new Map void>(); private readonly statusUpdateFingerprints = new Map(); @@ -158,19 +140,29 @@ export class CheckoutSession { constructor(options: CheckoutSessionOptions) { this.host = options.host; this.gitMutation = options.gitMutation; - this.workspaceGitService = options.workspaceGitService; + this.workspaceGitDirectory = options.workspaceGitDirectory; this.github = options.github; this.checkoutDiffManager = options.checkoutDiffManager; this.gitMetadataGenerator = options.gitMetadataGenerator; - this.paseoHome = options.paseoHome; - this.worktreesRoot = options.worktreesRoot; this.logger = options.logger; } + private resolveWorkspaceGit(input: { + cwd: string; + workspaceId?: string; + }): Promise { + const cwd = expandTilde(input.cwd); + return this.workspaceGitDirectory.resolve( + input.workspaceId === undefined + ? { kind: "legacy", cwd } + : { kind: "selected", workspaceId: input.workspaceId, cwd }, + ); + } + private async resolveForgeService( - cwd: string, + workspaceGit: WorkspaceGitWorkspace, ): Promise<{ forge: string; service: ForgeService } | null> { - const resolution = await this.workspaceGitService.resolveForge(cwd); + const resolution = await workspaceGit.resolveForge(); if (!resolution) { return null; } @@ -178,29 +170,32 @@ export class CheckoutSession { } private async requireForgeService( - cwd: string, + workspaceGit: WorkspaceGitWorkspace, ): Promise<{ forge: string; service: ForgeService }> { - const resolution = await this.resolveForgeService(cwd); + const resolution = await this.resolveForgeService(workspaceGit); if (!resolution) { - throw new NoResolvedForgeServiceError(cwd); + throw new NoResolvedForgeServiceError(workspaceGit.cwd); } return resolution; } - private async resolveForgeIdForError(cwd: string): Promise { + private async resolveForgeIdForError(workspaceGit: WorkspaceGitWorkspace): Promise { try { - return (await this.workspaceGitService.resolveForge(cwd))?.forge ?? "github"; + return (await workspaceGit.resolveForge())?.forge ?? "github"; } catch { return "github"; } } - private async resolveAuthStateForError(cwd: string, error: unknown): Promise { + private async resolveAuthStateForError( + workspaceGit: WorkspaceGitWorkspace, + error: unknown, + ): Promise { if (error instanceof NoResolvedForgeServiceError) { return error.authState; } try { - return (await this.workspaceGitService.resolveForge(cwd)) ? "error" : "no_remote"; + return (await workspaceGit.resolveForge()) ? "error" : "no_remote"; } catch { return "error"; } @@ -213,14 +208,17 @@ export class CheckoutSession { * independently in the same error path. */ private async resolveForgeContextForError( - cwd: string, + workspaceGit: WorkspaceGitWorkspace, error: unknown, ): Promise<{ forge: string; authState: ForgeAuthState }> { if (error instanceof NoResolvedForgeServiceError) { - return { forge: await this.resolveForgeIdForError(cwd), authState: error.authState }; + return { + forge: await this.resolveForgeIdForError(workspaceGit), + authState: error.authState, + }; } try { - const resolution = await this.workspaceGitService.resolveForge(cwd); + const resolution = await workspaceGit.resolveForge(); return { forge: resolution?.forge ?? "github", authState: resolution ? "error" : "no_remote", @@ -232,22 +230,21 @@ export class CheckoutSession { async handleStatusRequest(msg: CheckoutStatusRequest): Promise { const { cwd, requestId } = msg; - const resolvedCwd = expandTilde(cwd); - try { - const snapshot = await this.workspaceGitService.getSnapshot(resolvedCwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const snapshot = await workspaceGit.getSnapshot(); this.host.emit({ type: "checkout_status_response", - payload: buildCheckoutStatusPayloadFromSnapshot({ - cwd, - requestId, - snapshot, - }), + payload: { + ...(msg.workspaceId ? { workspaceId: msg.workspaceId } : {}), + ...buildCheckoutStatusPayloadFromSnapshot({ cwd, requestId, snapshot }), + }, }); } catch (error) { this.host.emit({ type: "checkout_status_response", payload: { + ...(msg.workspaceId ? { workspaceId: msg.workspaceId } : {}), cwd, isGit: false, repoRoot: null, @@ -271,7 +268,8 @@ export class CheckoutSession { const { cwd, requestId } = msg; try { - const { baseRef, commits } = await listCheckoutCommits({ cwd: expandTilde(cwd) }); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const { baseRef, commits } = await workspaceGit.listCommits(); this.host.emit({ type: "checkout.commits.list.response", payload: { cwd, baseRef, commits, error: null, requestId }, @@ -292,7 +290,11 @@ export class CheckoutSession { if (path.length === 0 || isAbsolute(path) || path.split(/[\\/]/).includes("..")) { throw new Error(`Invalid path: ${path}`); } - const file = await getCommitFileDiff({ cwd: expandTilde(cwd), sha, path }); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const file = await workspaceGit.getCommitFileDiff({ + sha, + path, + }); this.host.emit({ type: "checkout.commits.file_diff.response", payload: { cwd, sha, path, file, error: null, requestId }, @@ -306,13 +308,13 @@ export class CheckoutSession { } async handleValidateBranchRequest(msg: ValidateBranchRequest): Promise { - const { cwd, branchName, requestId } = msg; + const { branchName, requestId } = msg; try { - const resolvedCwd = expandTilde(cwd); assertSafeGitRef(branchName, "branch"); - const resolution = await this.workspaceGitService.validateBranchRef(resolvedCwd, branchName); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const resolution = await workspaceGit.validateBranchRef(branchName); switch (resolution.kind) { case "local": this.host.emit({ @@ -370,11 +372,11 @@ export class CheckoutSession { } async handleBranchSuggestionsRequest(msg: BranchSuggestionsRequest): Promise { - const { cwd, query, limit, requestId } = msg; + const { query, limit, requestId } = msg; try { - const resolvedCwd = expandTilde(cwd); - const branchDetails = await this.workspaceGitService.suggestBranchesForCwd(resolvedCwd, { + const workspaceGit = await this.resolveWorkspaceGit(msg); + const branchDetails = await workspaceGit.suggestBranches({ query, limit, }); @@ -401,20 +403,21 @@ export class CheckoutSession { } async handleSubscribeDiffRequest(msg: SubscribeCheckoutDiffRequest): Promise { - const cwd = expandTilde(msg.cwd); this.diffSubscriptions.get(msg.subscriptionId)?.(); const abort = new AbortController(); const unsubscribe = () => abort.abort(); this.diffSubscriptions.set(msg.subscriptionId, unsubscribe); try { - const subscription = await this.checkoutDiffManager.subscribe( - { cwd, compare: msg.compare, signal: abort.signal }, + const workspaceGit = await this.resolveWorkspaceGit(msg); + const subscription = await this.checkoutDiffManager.subscribeWorkspace( + { workspaceGit, compare: msg.compare, signal: abort.signal }, (snapshot) => { this.host.emit({ type: "checkout_diff_update", payload: { subscriptionId: msg.subscriptionId, + ...(msg.workspaceId ? { workspaceId: msg.workspaceId } : {}), ...snapshot, }, }); @@ -425,6 +428,7 @@ export class CheckoutSession { type: "subscribe_checkout_diff_response", payload: { subscriptionId: msg.subscriptionId, + ...(msg.workspaceId ? { workspaceId: msg.workspaceId } : {}), ...subscription.initial, requestId: msg.requestId, }, @@ -446,16 +450,11 @@ export class CheckoutSession { async handleRefreshRequest(msg: CheckoutRefreshRequest): Promise { const { cwd, requestId } = msg; - const resolvedCwd = expandTilde(cwd); try { - (await this.resolveForgeService(resolvedCwd))?.service.invalidate({ cwd: resolvedCwd }); - await this.workspaceGitService.getSnapshot(resolvedCwd, { - force: true, - includeForge: true, - reason: "manual-refresh", - }); - this.checkoutDiffManager.scheduleRefreshForCwd(resolvedCwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); + await workspaceGit.refresh({ priority: "high" }); + this.checkoutDiffManager.scheduleRefreshForWorkspace(workspaceGit); this.host.emit({ type: "checkout.refresh.response", payload: { @@ -478,10 +477,11 @@ export class CheckoutSession { } } - emitStatusUpdate(cwd: string, snapshot: WorkspaceGitRuntimeSnapshot): void { + emitStatusUpdate(workspaceId: string, cwd: string, snapshot: WorkspaceGitRuntimeSnapshot): void { try { - const requestId = `subscription:${cwd}`; + const requestId = `subscription:${workspaceId}`; const payload = { + workspaceId, ...buildCheckoutStatusPayloadFromSnapshot({ cwd, requestId, @@ -494,8 +494,8 @@ export class CheckoutSession { }), }; const fingerprint = JSON.stringify(payload); - if (this.statusUpdateFingerprints.get(cwd) === fingerprint) return; - this.statusUpdateFingerprints.set(cwd, fingerprint); + if (this.statusUpdateFingerprints.get(workspaceId) === fingerprint) return; + this.statusUpdateFingerprints.set(workspaceId, fingerprint); this.host.emit({ type: "checkout_status_update", payload, @@ -509,8 +509,8 @@ export class CheckoutSession { * Notify the live diff subscriptions that the working tree at `cwd` changed. * Called by the command handlers below after they mutate the repository. */ - private scheduleDiffRefresh(cwd: string): void { - this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + private scheduleDiffRefresh(workspaceGit: WorkspaceGitWorkspace): void { + this.checkoutDiffManager.scheduleRefreshForWorkspace(workspaceGit); } // --------------------------------------------------------------------------- @@ -523,8 +523,11 @@ export class CheckoutSession { const { cwd, branch, requestId } = msg; try { - const checkoutResult = await this.gitMutation.checkoutExistingBranch(cwd, branch); - this.scheduleDiffRefresh(cwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const checkoutResult = await this.gitMutation + .bind(workspaceGit) + .checkoutExistingBranch(branch); + this.scheduleDiffRefresh(workspaceGit); // Push a workspace_update immediately so the sidebar/header reflect // the new branch name without waiting for the background git watcher. @@ -574,10 +577,18 @@ export class CheckoutSession { } try { - const result = await this.host.renameCurrentBranch(cwd, branch); - await this.gitMutation.notifyGitMutation(cwd, "rename-branch", { invalidateForge: true }); - this.scheduleDiffRefresh(cwd); - this.host.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const result = await workspaceGit.renameBranch(branch); + await this.gitMutation.bind(workspaceGit).notify("rename-branch", { + invalidateForge: true, + }); + this.scheduleDiffRefresh(workspaceGit); + this.host.handleWorkspaceGitBranchSnapshot( + msg.workspaceId === undefined + ? { kind: "legacy", cwd } + : { kind: "selected", workspaceId: msg.workspaceId, cwd }, + result.currentBranch, + ); // Branch is a git fact derived per-descriptor from each workspace's own // live git snapshot (id → cwd); the reconciliation pass re-persists the @@ -616,9 +627,10 @@ export class CheckoutSession { ): Promise { const { cwd, paths, requestId } = msg; try { - await discardChanges(cwd, paths); - await this.gitMutation.notifyGitMutation(cwd, "discard-changes"); - this.scheduleDiffRefresh(cwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); + await workspaceGit.discardChanges(paths); + await this.gitMutation.bind(workspaceGit).notify("discard-changes"); + this.scheduleDiffRefresh(workspaceGit); this.host.emit({ type: "checkout.discard_changes.response", payload: { cwd, success: true, error: null, requestId }, @@ -636,16 +648,14 @@ export class CheckoutSession { ): Promise { const { cwd, requestId } = msg; try { + const workspaceGit = await this.resolveWorkspaceGit(msg); const branchLabel = msg.branch?.trim() ?? ""; const message = branchLabel ? `${CheckoutSession.PASEO_STASH_PREFIX} ${branchLabel}` : `${CheckoutSession.PASEO_STASH_PREFIX} unnamed`; - await runGitCommand(["stash", "push", "--include-untracked", "-m", message], { - cwd, - timeout: 120_000, - }); - await this.gitMutation.notifyGitMutation(cwd, "stash-push"); - this.scheduleDiffRefresh(cwd); + await workspaceGit.stashPush(message); + await this.gitMutation.bind(workspaceGit).notify("stash-push"); + this.scheduleDiffRefresh(workspaceGit); this.host.emit({ type: "stash_save_response", payload: { cwd, success: true, error: null, requestId }, @@ -663,12 +673,10 @@ export class CheckoutSession { ): Promise { const { cwd, stashIndex, requestId } = msg; try { - await runGitCommand(["stash", "pop", `stash@{${stashIndex}}`], { - cwd, - timeout: 120_000, - }); - await this.gitMutation.notifyGitMutation(cwd, "stash-pop"); - this.scheduleDiffRefresh(cwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); + await workspaceGit.stashPop(stashIndex); + await this.gitMutation.bind(workspaceGit).notify("stash-pop"); + this.scheduleDiffRefresh(workspaceGit); this.host.emit({ type: "stash_pop_response", payload: { cwd, success: true, error: null, requestId }, @@ -687,7 +695,8 @@ export class CheckoutSession { const { cwd, requestId } = msg; const paseoOnly = msg.paseoOnly !== false; try { - const entries = await this.workspaceGitService.listStashes(cwd, { paseoOnly }); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const entries = await workspaceGit.listStashes({ paseoOnly }); this.host.emit({ type: "stash_list_response", @@ -707,20 +716,24 @@ export class CheckoutSession { const { cwd, requestId } = msg; try { + const workspaceGit = await this.resolveWorkspaceGit(msg); let message = msg.message?.trim() ?? ""; if (!message) { + if (msg.workspaceId) { + throw new Error("Selected workspace Git requires an explicit commit message"); + } message = await this.gitMetadataGenerator.generateCommitMessage(cwd); } if (!message) { throw new Error("Commit message is required"); } - await commitChanges(cwd, { + await workspaceGit.commit({ message, addAll: msg.addAll ?? true, }); - await this.gitMutation.notifyGitMutation(cwd, "commit-changes"); - this.scheduleDiffRefresh(cwd); + await this.gitMutation.bind(workspaceGit).notify("commit-changes"); + this.scheduleDiffRefresh(workspaceGit); this.host.emit({ type: "checkout_commit_response", @@ -750,7 +763,8 @@ export class CheckoutSession { const { cwd, requestId } = msg; try { - const snapshot = await this.workspaceGitService.getSnapshot(cwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const snapshot = await workspaceGit.getSnapshot(); if (!snapshot.git.isGit) { throw new Error(`Not a git repository: ${cwd}`); } @@ -769,19 +783,18 @@ export class CheckoutSession { baseRef = baseRef.slice("origin/".length); } - const mutatedCwd = await mergeToBase( - cwd, - { - baseRef, - mode: msg.strategy === "squash" ? "squash" : "merge", - }, - { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot }, - ); - await Promise.all([ - this.gitMutation.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateForge: true }), - ...(mutatedCwd !== cwd ? [this.gitMutation.notifyGitMutation(cwd, "merge-to-base")] : []), - ]); - this.scheduleDiffRefresh(cwd); + const mutatedCwd = await workspaceGit.mergeToBase({ + baseRef, + mode: msg.strategy === "squash" ? "squash" : "merge", + }); + const mutatedWorkspaceGit = + mutatedCwd === workspaceGit.cwd + ? workspaceGit + : await this.workspaceGitDirectory.resolve({ kind: "legacy", cwd: mutatedCwd }); + await this.gitMutation.bind(mutatedWorkspaceGit).notify("merge-to-base", { + invalidateForge: true, + }); + this.scheduleDiffRefresh(mutatedWorkspaceGit); this.host.emit({ type: "checkout_merge_response", @@ -811,19 +824,22 @@ export class CheckoutSession { const { cwd, requestId } = msg; try { + const workspaceGit = await this.resolveWorkspaceGit(msg); if (msg.requireCleanTarget ?? true) { - const snapshot = await this.workspaceGitService.getSnapshot(cwd); + const snapshot = await workspaceGit.getSnapshot(); if (snapshot.git.isDirty) { throw new Error("Working directory has uncommitted changes."); } } - await mergeFromBase(cwd, { + await workspaceGit.mergeFromBase({ baseRef: msg.baseRef, requireCleanTarget: msg.requireCleanTarget ?? true, }); - await this.gitMutation.notifyGitMutation(cwd, "merge-from-base", { invalidateForge: true }); - this.scheduleDiffRefresh(cwd); + await this.gitMutation.bind(workspaceGit).notify("merge-from-base", { + invalidateForge: true, + }); + this.scheduleDiffRefresh(workspaceGit); this.host.emit({ type: "checkout_merge_from_base_response", @@ -853,9 +869,10 @@ export class CheckoutSession { const { cwd, requestId } = msg; try { - await pullCurrentBranch(cwd); - await this.gitMutation.notifyGitMutation(cwd, "pull", { invalidateForge: true }); - this.scheduleDiffRefresh(cwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); + await workspaceGit.pull(); + await this.gitMutation.bind(workspaceGit).notify("pull", { invalidateForge: true }); + this.scheduleDiffRefresh(workspaceGit); this.host.emit({ type: "checkout_pull_response", @@ -885,8 +902,9 @@ export class CheckoutSession { const { cwd, requestId } = msg; try { - await pushCurrentBranch(cwd); - await this.gitMutation.notifyGitMutation(cwd, "push", { invalidateForge: true }); + const workspaceGit = await this.resolveWorkspaceGit(msg); + await workspaceGit.push(); + await this.gitMutation.bind(workspaceGit).notify("push", { invalidateForge: true }); this.host.emit({ type: "checkout_push_response", payload: { @@ -915,6 +933,10 @@ export class CheckoutSession { const { cwd, requestId } = msg; try { + const workspaceGit = await this.resolveWorkspaceGit(msg); + if (msg.workspaceId) { + throw new Error("Selected workspace Git does not support pull requests"); + } let title = msg.title?.trim() ?? ""; let body = msg.body?.trim() ?? ""; @@ -924,7 +946,7 @@ export class CheckoutSession { if (!body) body = generated.body; } - const { service } = await this.requireForgeService(cwd); + const { service } = await this.requireForgeService(workspaceGit); const result = await createPullRequest( cwd, { @@ -934,7 +956,7 @@ export class CheckoutSession { }, service, ); - await this.gitMutation.notifyGitMutation(cwd, "create-pr", { invalidateForge: true }); + await this.gitMutation.bind(workspaceGit).notify("create-pr", { invalidateForge: true }); this.host.emit({ type: "checkout_pr_create_response", @@ -966,19 +988,20 @@ export class CheckoutSession { const { cwd, requestId } = msg; try { - const pullRequest = await this.resolveCurrentPullRequest(cwd, "merge", { + const workspaceGit = await this.resolveWorkspaceGit(msg); + const pullRequest = await this.resolveCurrentPullRequest(workspaceGit, "merge", { force: true, includeForge: true, reason: "merge-pr-validation", }); - const { service } = await this.requireForgeService(cwd); + const { service } = await this.requireForgeService(workspaceGit); await service.mergePullRequest({ cwd, prNumber: pullRequest.number, mergeMethod: msg.mergeMethod, status: pullRequest, }); - await this.gitMutation.notifyGitMutation(cwd, "merge-pr", { invalidateForge: true }); + await this.gitMutation.bind(workspaceGit).notify("merge-pr", { invalidateForge: true }); this.host.emit({ type: "checkout_pr_merge_response", @@ -1017,12 +1040,13 @@ export class CheckoutSession { : "checkout.github.set_auto_merge.response"; try { - const pullRequest = await this.resolveCurrentPullRequest(cwd, "auto-merge", { + const workspaceGit = await this.resolveWorkspaceGit(msg); + const pullRequest = await this.resolveCurrentPullRequest(workspaceGit, "auto-merge", { force: true, includeForge: true, reason: "auto-merge-validation", }); - const { service } = await this.requireForgeService(cwd); + const { service } = await this.requireForgeService(workspaceGit); if (msg.enabled) { const mergeMethod = msg.mergeMethod; if (!mergeMethod) { @@ -1044,13 +1068,11 @@ export class CheckoutSession { status: pullRequest, }); } - await this.gitMutation.notifyGitMutation( - cwd, - msg.enabled ? "enable-pr-auto-merge" : "disable-pr-auto-merge", - { + await this.gitMutation + .bind(workspaceGit) + .notify(msg.enabled ? "enable-pr-auto-merge" : "disable-pr-auto-merge", { invalidateForge: true, - }, - ); + }); this.host.emit({ type: responseType, @@ -1077,11 +1099,11 @@ export class CheckoutSession { } private async resolveCurrentPullRequest( - cwd: string, + workspaceGit: WorkspaceGitWorkspace, operation: "merge" | "auto-merge", options?: WorkspaceGitSnapshotOptions, ): Promise { - const snapshot = await this.workspaceGitService.getSnapshot(cwd, options); + const snapshot = await workspaceGit.getSnapshot(options); const pullRequest = snapshot.forge.pullRequest; if (!pullRequest || typeof pullRequest.number !== "number") { throw new Error(`Unable to determine current change request number for ${operation}`); @@ -1093,9 +1115,11 @@ export class CheckoutSession { msg: Extract, ): Promise { const { cwd, requestId } = msg; + let workspaceGit: WorkspaceGitWorkspace | null = null; try { - const snapshot = await this.workspaceGitService.getSnapshot(cwd); + workspaceGit = await this.resolveWorkspaceGit(msg); + const snapshot = await workspaceGit.getSnapshot(); this.host.emit({ type: "checkout_pr_status_response", payload: buildCheckoutPrStatusPayloadFromSnapshot({ @@ -1105,7 +1129,9 @@ export class CheckoutSession { }), }); } catch (error) { - const { forge, authState } = await this.resolveForgeContextForError(cwd, error); + const { forge, authState } = workspaceGit + ? await this.resolveForgeContextForError(workspaceGit, error) + : { forge: "github", authState: "error" as const }; this.host.emit({ type: "checkout_pr_status_response", payload: { @@ -1145,7 +1171,8 @@ export class CheckoutSession { return; } - const resolvedForge = await this.resolveForgeService(cwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const resolvedForge = await this.resolveForgeService(workspaceGit); if (!resolvedForge) { this.host.emit({ type: "pull_request_timeline_response", @@ -1270,7 +1297,8 @@ export class CheckoutSession { "Check details request must address a check by checkRunId or workflowRunId", ); } - const { service } = await this.requireForgeService(cwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); + const { service } = await this.requireForgeService(workspaceGit); const details = await service.getCheckDetails({ cwd, repoOwner, @@ -1313,13 +1341,17 @@ export class CheckoutSession { try { const resolvedCwd = expandTilde(cwd); + const workspaceGit = await this.resolveWorkspaceGit(msg); // COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 — // the legacy github_search RPC is GitHub by definition; the modern // forge.search RPC resolves the cwd's forge. - const resolvedForge = - msg.type === "github_search_request" - ? { forge: "github", service: this.github } - : await this.resolveForgeService(resolvedCwd); + let resolvedForge: { forge: string; service: ForgeService } | null; + if (msg.type === "github_search_request") { + await workspaceGit.resolveForge(); + resolvedForge = { forge: "github", service: this.github }; + } else { + resolvedForge = await this.resolveForgeService(workspaceGit); + } if (!resolvedForge) { if (msg.type === "github_search_request") { this.host.emit({ @@ -1383,8 +1415,12 @@ export class CheckoutSession { }, }); } catch (error) { - const resolvedCwd = expandTilde(cwd); - const authState = await this.resolveAuthStateForError(resolvedCwd, error); + let authState: ForgeAuthState = "error"; + try { + authState = await this.resolveAuthStateForError(await this.resolveWorkspaceGit(msg), error); + } catch { + authState = "error"; + } if (msg.type === "github_search_request") { this.host.emit({ type: "github_search_response", diff --git a/packages/server/src/server/session/files/workspace-files-session.test.ts b/packages/server/src/server/session/files/workspace-files-session.test.ts index d176e03bf8..cc7516e383 100644 --- a/packages/server/src/server/session/files/workspace-files-session.test.ts +++ b/packages/server/src/server/session/files/workspace-files-session.test.ts @@ -22,6 +22,8 @@ import { } from "./workspace-files-session.js"; import { DownloadTokenStore } from "../../file-download/token-store.js"; import type { SessionOutboundMessage } from "../../messages.js"; +import { createWorkspaceRuntimeService } from "../../workspace-runtime/index.js"; +import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; const tempDirs: string[] = []; @@ -41,6 +43,10 @@ function makeSubsystem( options: { hasBinaryChannel?: boolean; emitBinary?: (frame: Uint8Array) => Promise | void; + selectedWorkspace?: { + record: PersistedWorkspaceRecord; + runtime: ReturnType; + }; } = {}, ) { const emitted: SessionOutboundMessage[] = []; @@ -55,17 +61,31 @@ function makeSubsystem( hasBinaryChannel: () => hasBinary, }; const paseoHome = makeDir("workspace-files-home-"); + const downloadTokenStore = new DownloadTokenStore({ ttlMs: 60_000 }); const subsystem = new WorkspaceFilesSession({ host, - downloadTokenStore: new DownloadTokenStore({ ttlMs: 60_000 }), + downloadTokenStore, paseoHome, logger: pino({ level: "silent" }), + ...(options.selectedWorkspace + ? { + workspaceRuntime: options.selectedWorkspace.runtime, + workspaceRegistry: { + get: async (workspaceId: string) => + workspaceId === options.selectedWorkspace?.record.workspaceId + ? options.selectedWorkspace.record + : null, + list: async () => [options.selectedWorkspace!.record], + }, + } + : {}), }); return { subsystem, emitted, binary, paseoHome, + downloadTokenStore, setHasBinary: (value: boolean) => { hasBinary = value; }, @@ -80,7 +100,205 @@ function uploadFrame(args: Parameters[0]): FileT return frame; } +async function collectUpload(chunks: AsyncIterable): Promise { + const buffers: Buffer[] = []; + for await (const chunk of chunks) buffers.push(Buffer.from(chunk)); + return Buffer.concat(buffers); +} + describe("WorkspaceFilesSession", () => { + test("routes the complete selected-workspace file surface through workspace runtime", async () => { + const cwd = makeDir("workspace-files-selected-"); + writeFileSync(join(cwd, "runtime.txt"), "before\n"); + writeFileSync(join(cwd, "favicon.svg"), ''); + const runtimeIds = new Map(); + const runtime = createWorkspaceRuntimeService({ + paseoHome: makeDir("workspace-files-runtime-home-"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => { + runtimeIds.set(workspaceId, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, + }); + await runtime.create({ + workspaceId: "selected-workspace", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: cwd } }, + placement: { kind: "existing" }, + }); + const now = new Date().toISOString(); + const record: PersistedWorkspaceRecord = { + workspaceId: "selected-workspace", + projectId: "project", + cwd, + kind: "directory", + displayName: "selected", + title: null, + branch: null, + worktreeRoot: null, + baseBranch: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + runtime: { runtimeId: "local" }, + createdAt: now, + updatedAt: now, + archivedAt: null, + autoArchivedChangeRequestUrl: null, + pinnedAt: null, + }; + const { subsystem, emitted, binary, downloadTokenStore } = makeSubsystem({ + hasBinaryChannel: true, + selectedWorkspace: { record, runtime }, + }); + + await subsystem.handleFileExplorerRequest({ + type: "file_explorer_request", + workspaceId: record.workspaceId, + cwd, + path: ".", + mode: "list", + requestId: "selected-list", + }); + const listingMessage = emitted.at(-1); + if (listingMessage?.type !== "file_explorer_response") { + throw new Error("Expected selected file listing"); + } + expect(listingMessage.payload.directory?.entries.map((entry) => entry.name)).toContain( + "runtime.txt", + ); + + await subsystem.handleFileExplorerRequest({ + type: "file_explorer_request", + workspaceId: record.workspaceId, + cwd, + path: "runtime.txt", + mode: "file", + acceptBinary: true, + requestId: "selected-read", + }); + expect(binary.map((frame) => decodeFileTransferFrame(frame)?.opcode)).toEqual([ + FileTransferOpcode.FileBegin, + FileTransferOpcode.FileChunk, + FileTransferOpcode.FileEnd, + ]); + + const version = await runtime.files(record.workspaceId).stat("runtime.txt"); + if (version.status !== "ready") throw new Error("Expected runtime.txt to exist"); + await subsystem.handleFileWriteRequest({ + type: "fs.file.write.request", + workspaceId: record.workspaceId, + cwd, + path: "runtime.txt", + content: "after\n", + expectedModifiedAt: version.modifiedAt, + expectedRevision: version.revision, + requestId: "selected-write", + }); + expect(emitted.at(-1)).toMatchObject({ + type: "fs.file.write.response", + payload: { result: { status: "written", size: 6 } }, + }); + + await subsystem.handleFileDownloadTokenRequest({ + type: "file_download_token_request", + workspaceId: record.workspaceId, + cwd, + path: "runtime.txt", + requestId: "selected-download", + }); + const tokenMessage = emitted.at(-1); + if (tokenMessage?.type !== "file_download_token_response" || !tokenMessage.payload.token) { + throw new Error("Expected selected download token"); + } + const download = downloadTokenStore.consumeToken(tokenMessage.payload.token); + expect(download?.absolutePath).toBeUndefined(); + const opened = await download?.open?.(); + expect(opened && Buffer.from(await collectUpload(opened.chunks)).toString("utf8")).toBe( + "after\n", + ); + await subsystem.handleProjectIconRequest({ + type: "project_icon_request", + workspaceId: record.workspaceId, + cwd, + requestId: "selected-icon", + }); + expect(emitted.at(-1)).toMatchObject({ + type: "project_icon_response", + payload: { icon: { mimeType: "image/svg+xml" }, error: null }, + }); + await subsystem.dispose(); + await runtime.destroy(record.workspaceId); + }); + + test("never selects a workspace runtime from compatibility cwd", async () => { + const cwd = makeDir("workspace-files-selected-cwd-"); + writeFileSync(join(cwd, "runtime.txt"), "runtime only\n"); + const runtimeIds = new Map(); + const runtime = createWorkspaceRuntimeService({ + paseoHome: makeDir("workspace-files-selected-cwd-home-"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => { + runtimeIds.set(workspaceId, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, + }); + await runtime.create({ + workspaceId: "selected-workspace", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: cwd } }, + placement: { kind: "existing" }, + }); + const now = new Date().toISOString(); + const record: PersistedWorkspaceRecord = { + workspaceId: "selected-workspace", + projectId: "project", + cwd, + kind: "directory", + displayName: "selected", + title: null, + branch: null, + worktreeRoot: null, + baseBranch: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + runtime: { runtimeId: "local" }, + createdAt: now, + updatedAt: now, + archivedAt: null, + autoArchivedChangeRequestUrl: null, + pinnedAt: null, + }; + const { subsystem, emitted } = makeSubsystem({ + selectedWorkspace: { record, runtime }, + }); + + await subsystem.handleFileExplorerRequest({ + type: "file_explorer_request", + cwd, + path: "runtime.txt", + mode: "file", + requestId: "selected-without-id", + }); + + expect(emitted).toEqual([ + expect.objectContaining({ + type: "file_explorer_response", + payload: expect.objectContaining({ + requestId: "selected-without-id", + error: "workspaceId is required for a selected workspace file operation", + }), + }), + ]); + await subsystem.dispose(); + await runtime.destroy(record.workspaceId); + }); + test("creates an entry and emits the complete success response", async () => { const cwd = makeDir("workspace-files-create-"); const { subsystem, emitted } = makeSubsystem(); diff --git a/packages/server/src/server/session/files/workspace-files-session.ts b/packages/server/src/server/session/files/workspace-files-session.ts index c74de3acae..97be4ba1ca 100644 --- a/packages/server/src/server/session/files/workspace-files-session.ts +++ b/packages/server/src/server/session/files/workspace-files-session.ts @@ -1,4 +1,5 @@ import type pino from "pino"; +import { areEquivalentPaths } from "../../../utils/path.js"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; import { encodeFileTransferFrame, @@ -19,6 +20,9 @@ import type { SessionInboundMessage, SessionOutboundMessage, } from "../../messages.js"; +import type { WorkspaceFileKind, WorkspaceFiles } from "@getpaseo/workspace-helper"; +import type { WorkspaceRuntimeService } from "../../workspace-runtime/index.js"; +import type { WorkspaceRegistry } from "../../workspace-registry.js"; import { FileUploadStore } from "../../file-upload/index.js"; import type { DownloadTokenStore } from "../../file-download/token-store.js"; import { @@ -33,7 +37,14 @@ import { writeExplorerFile, } from "../../file-explorer/service.js"; import { workspaceFileObserver, type FileObserver } from "../../file-explorer/observer.js"; -import { getProjectIcon } from "../../../utils/project-icon.js"; +import { + getProjectIcon, + ICON_PATTERNS, + IGNORED_DIRS, + MONOREPO_PACKAGE_DIRS, + PRIORITY_DIRS, + projectIconFromBytes, +} from "../../../utils/project-icon.js"; /** * What a workspace file-access request reaches outside its own domain: the @@ -53,6 +64,8 @@ export interface WorkspaceFilesSessionOptions { paseoHome: string; logger: pino.Logger; fileObserver?: FileObserver; + workspaceRuntime?: WorkspaceRuntimeService; + workspaceRegistry?: Pick; } /** @@ -68,7 +81,9 @@ export class WorkspaceFilesSession { private readonly logger: pino.Logger; private readonly fileUploads: FileUploadStore; private readonly fileObserver: FileObserver; - private readonly fileSubscriptions = new Map void>(); + private readonly workspaceRuntime: WorkspaceRuntimeService | null; + private readonly workspaceRegistry: Pick | null; + private readonly fileSubscriptions = new Map Promise>(); constructor(options: WorkspaceFilesSessionOptions) { this.host = options.host; @@ -76,11 +91,68 @@ export class WorkspaceFilesSession { this.logger = options.logger; this.fileUploads = new FileUploadStore({ paseoHome: options.paseoHome }); this.fileObserver = options.fileObserver ?? workspaceFileObserver; + this.workspaceRuntime = options.workspaceRuntime ?? null; + this.workspaceRegistry = options.workspaceRegistry ?? null; } async handleFileSubscribeRequest(request: FileSubscribeRequest): Promise { - this.fileSubscriptions.get(request.subscriptionId)?.(); + await this.fileSubscriptions.get(request.subscriptionId)?.(); try { + const selectedFiles = await this.resolveSelectedFiles(request.cwd, request.workspaceId); + if (selectedFiles) { + const subscription = await selectedFiles.subscribe({ paths: [request.path] }, (event) => { + if (event.type === "overflow") return; + if (event.type === "error") { + this.host.emit({ + type: "fs.file.update", + payload: { + subscriptionId: request.subscriptionId, + version: { + status: "error", + cwd: request.cwd, + path: request.path, + error: event.error, + }, + }, + }); + return; + } + void selectedFiles.stat(request.path).then( + (version) => + this.host.emit({ + type: "fs.file.update", + payload: { + subscriptionId: request.subscriptionId, + version: toProtocolVersion(request.cwd, version), + }, + }), + (error) => + this.host.emit({ + type: "fs.file.update", + payload: { + subscriptionId: request.subscriptionId, + version: { + status: "error", + cwd: request.cwd, + path: request.path, + error: getErrorMessage(error), + }, + }, + }), + ); + }); + const initial = toProtocolVersion(request.cwd, await selectedFiles.stat(request.path)); + this.fileSubscriptions.set(request.subscriptionId, () => subscription.unsubscribe()); + this.host.emit({ + type: "fs.file.subscribe.response", + payload: { + subscriptionId: request.subscriptionId, + initial, + requestId: request.requestId, + }, + }); + return; + } const subscription = await this.fileObserver.subscribe( { cwd: request.cwd, path: request.path }, (version) => { @@ -90,7 +162,7 @@ export class WorkspaceFilesSession { }); }, ); - this.fileSubscriptions.set(request.subscriptionId, subscription.unsubscribe); + this.fileSubscriptions.set(request.subscriptionId, async () => subscription.unsubscribe()); this.host.emit({ type: "fs.file.subscribe.response", payload: { @@ -116,8 +188,8 @@ export class WorkspaceFilesSession { } } - handleFileUnsubscribeRequest(request: FileUnsubscribeRequest): void { - this.fileSubscriptions.get(request.subscriptionId)?.(); + async handleFileUnsubscribeRequest(request: FileUnsubscribeRequest): Promise { + await this.fileSubscriptions.get(request.subscriptionId)?.(); this.fileSubscriptions.delete(request.subscriptionId); this.host.emit({ type: "fs.file.unsubscribe.response", @@ -126,13 +198,24 @@ export class WorkspaceFilesSession { } async handleFileWriteRequest(request: FileWriteRequest): Promise { - const result = await writeExplorerFile({ - root: request.cwd, - relativePath: request.path, - content: request.content, - expectedModifiedAt: request.expectedModifiedAt, - expectedRevision: request.expectedRevision, - }); + const selectedFiles = await this.resolveSelectedFiles(request.cwd, request.workspaceId); + const result = selectedFiles + ? toProtocolWriteResult( + request.cwd, + await selectedFiles.write({ + path: request.path, + contents: Buffer.from(request.content, "utf8"), + expectedModifiedAt: request.expectedModifiedAt, + expectedRevision: request.expectedRevision, + }), + ) + : await writeExplorerFile({ + root: request.cwd, + relativePath: request.path, + content: request.content, + expectedModifiedAt: request.expectedModifiedAt, + expectedRevision: request.expectedRevision, + }); this.host.emit({ type: "fs.file.write.response", payload: { result, requestId: request.requestId }, @@ -213,8 +296,8 @@ export class WorkspaceFilesSession { }); } - dispose(): void { - for (const unsubscribe of this.fileSubscriptions.values()) unsubscribe(); + async dispose(): Promise { + await Promise.all([...this.fileSubscriptions.values()].map((unsubscribe) => unsubscribe())); this.fileSubscriptions.clear(); } @@ -241,11 +324,11 @@ export class WorkspaceFilesSession { } try { + const selectedFiles = await this.resolveSelectedFiles(cwd, request.workspaceId); if (mode === "list") { - const directory = await listDirectoryEntries({ - root: cwd, - relativePath: requestedPath, - }); + const directory = selectedFiles + ? await selectedFiles.list(requestedPath) + : await listDirectoryEntries({ root: cwd, relativePath: requestedPath }); this.host.emit( { @@ -263,6 +346,39 @@ export class WorkspaceFilesSession { source, ); } else { + if (selectedFiles) { + const file = await selectedFiles.read(requestedPath); + if (request.acceptBinary && this.host.hasBinaryChannel()) { + await this.emitStream(requestId, file, source); + } else { + const bytes = await collectBytes(file.chunks); + this.host.emit( + { + type: "file_explorer_response", + payload: { + cwd, + path: file.path, + mode, + directory: null, + file: { + path: file.path, + kind: file.kind, + encoding: explorerEncoding(file.kind), + ...explorerContent(file.kind, bytes), + mimeType: file.mimeType, + size: file.size, + modifiedAt: file.modifiedAt, + revision: file.revision, + }, + error: null, + requestId, + }, + }, + source, + ); + } + return; + } if (request.acceptBinary && this.host.hasBinaryChannel()) { await streamExplorerFile({ root: cwd, relativePath: requestedPath }, async (file) => { await this.host.emitBinary( @@ -360,7 +476,10 @@ export class WorkspaceFilesSession { const { cwd, requestId } = request; try { - const icon = await getProjectIcon(cwd); + const selectedFiles = await this.resolveSelectedFiles(cwd, request.workspaceId); + const icon = selectedFiles + ? await getWorkspaceProjectIcon(selectedFiles) + : await getProjectIcon(cwd); this.host.emit({ type: "project_icon_response", payload: { @@ -409,6 +528,37 @@ export class WorkspaceFilesSession { ); try { + const selectedFiles = await this.resolveSelectedFiles(cwd, request.workspaceId); + if (selectedFiles) { + const info = await selectedFiles.stat(requestedPath); + if (info.status !== "ready") { + throw new Error(info.status === "error" ? info.error : "File not found"); + } + const entry = this.downloadTokenStore.issueToken({ + path: info.path, + fileName: info.path.split("/").at(-1) ?? "download", + mimeType: info.mimeType, + size: info.size, + open: async () => { + const file = await selectedFiles.read(requestedPath); + return { chunks: file.chunks, size: file.size }; + }, + }); + this.host.emit({ + type: "file_download_token_response", + payload: { + cwd, + path: info.path, + token: entry.token, + fileName: entry.fileName, + mimeType: entry.mimeType, + size: entry.size, + error: null, + requestId, + }, + }); + return; + } const info = await getDownloadableFileInfo({ root: cwd, relativePath: requestedPath, @@ -455,4 +605,131 @@ export class WorkspaceFilesSession { }); } } + + private async resolveSelectedFiles( + cwd: string, + workspaceId: string | undefined, + ): Promise { + if (!this.workspaceRuntime || !this.workspaceRegistry) return null; + if (workspaceId) { + const workspace = await this.workspaceRegistry.get(workspaceId); + if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`); + return workspace.runtime ? this.workspaceRuntime.files(workspaceId) : null; + } + if ( + (await this.workspaceRegistry.list()).some( + (workspace) => + workspace.archivedAt === null && + workspace.runtime && + areEquivalentPaths(workspace.cwd, cwd), + ) + ) { + throw new Error("workspaceId is required for a selected workspace file operation"); + } + return null; + } + + private async emitStream( + requestId: string, + file: Awaited>, + source?: object, + ): Promise { + await this.host.emitBinary( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId, + metadata: { + mime: file.mimeType, + size: file.size, + encoding: file.encoding, + modifiedAt: file.modifiedAt, + revision: file.revision, + }, + }), + source, + ); + for await (const chunk of file.chunks) { + await this.host.emitBinary( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId, + payload: chunk, + }), + source, + ); + } + await this.host.emitBinary( + encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId }), + source, + ); + } +} + +function explorerEncoding(kind: WorkspaceFileKind): "base64" | "none" | "utf-8" { + if (kind === "image") return "base64"; + if (kind === "binary") return "none"; + return "utf-8"; +} + +function explorerContent(kind: WorkspaceFileKind, bytes: Uint8Array): { content?: string } { + if (kind === "image") return { content: Buffer.from(bytes).toString("base64") }; + if (kind === "text") return { content: Buffer.from(bytes).toString("utf8") }; + return {}; +} + +function toProtocolVersion(cwd: string, version: Awaited>) { + return { ...version, cwd }; +} + +function toProtocolWriteResult(cwd: string, result: Awaited>) { + return result.status === "conflict" + ? { ...result, version: toProtocolVersion(cwd, result.version) } + : result; +} + +async function collectBytes(chunks: AsyncIterable): Promise { + const buffers: Buffer[] = []; + for await (const chunk of chunks) buffers.push(Buffer.from(chunk)); + return Buffer.concat(buffers); +} + +async function getWorkspaceProjectIcon(files: WorkspaceFiles) { + const candidates = [...PRIORITY_DIRS, ...MONOREPO_PACKAGE_DIRS, "."]; + const ignored = new Set(IGNORED_DIRS); + for (const root of candidates) { + const icon = await findWorkspaceIcon(files, root, ignored, root === "." ? 0 : 2); + if (!icon) continue; + const read = await files.read(icon); + if (read.size > 32 * 1024) continue; + return projectIconFromBytes(icon, await collectBytes(read.chunks)); + } + return null; +} + +async function findWorkspaceIcon( + files: WorkspaceFiles, + directory: string, + ignored: ReadonlySet, + depth: number, +): Promise { + let listing: Awaited>; + try { + listing = await files.list(directory); + } catch { + return null; + } + for (const pattern of ICON_PATTERNS) { + const expression = new RegExp(`^${pattern.replace(/\./g, "\\.").replace(/\*/g, ".*")}$`); + const match = listing.entries.find( + (entry) => entry.kind === "file" && expression.test(entry.name), + ); + if (match) return match.path; + } + if (depth === 0) return null; + for (const entry of listing.entries) { + if (entry.kind !== "directory" || ignored.has(entry.name)) continue; + const nested = await findWorkspaceIcon(files, entry.path, ignored, depth - 1); + if (nested) return nested; + } + return null; } diff --git a/packages/server/src/server/session/git-mutation/git-mutation-service.test.ts b/packages/server/src/server/session/git-mutation/git-mutation-service.test.ts index 1910722765..a37d7bf800 100644 --- a/packages/server/src/server/session/git-mutation/git-mutation-service.test.ts +++ b/packages/server/src/server/session/git-mutation/git-mutation-service.test.ts @@ -11,14 +11,19 @@ import type { } from "../../workspace-git-service.js"; import { createGitMutationService } from "./git-mutation-service.js"; -// The production module reads only WorkspaceGitService.{validateBranchRef,getSnapshot, -// hasLocalBranch,invalidateForge}. The fake below implements exactly that slice as an +// The production module reads only the named WorkspaceGitService branch capabilities. The fake +// below implements exactly that slice as an // in-memory adapter; the happy-path tests cross the real git boundary against a temp repo, // since that is where checkoutResolvedBranch / `git checkout -b` actually run. type GitSource = Pick< WorkspaceGitService, - "validateBranchRef" | "getSnapshot" | "hasLocalBranch" | "invalidateForge" + | "validateBranchRef" + | "getSnapshot" + | "hasLocalBranch" + | "invalidateForge" + | "switchBranch" + | "createBranch" >; const logger = pino({ level: "silent" }); @@ -53,6 +58,16 @@ function createFakeGit(opts: FakeGitOptions = {}) { invalidateForge(cwd) { invalidateCalls.push({ cwd }); }, + async switchBranch(cwd, branch) { + execFileSync("git", ["checkout", branch], { cwd, stdio: "pipe" }); + return { source: resolution.kind === "remote" ? "remote" : "local" }; + }, + async createBranch(cwd, options) { + execFileSync("git", ["checkout", "-b", options.branch, options.baseRef], { + cwd, + stdio: "pipe", + }); + }, }; return { git, snapshotCalls, invalidateCalls }; } diff --git a/packages/server/src/server/session/git-mutation/git-mutation-service.ts b/packages/server/src/server/session/git-mutation/git-mutation-service.ts index 7611ba89bc..20d2f66213 100644 --- a/packages/server/src/server/session/git-mutation/git-mutation-service.ts +++ b/packages/server/src/server/session/git-mutation/git-mutation-service.ts @@ -1,12 +1,10 @@ import type pino from "pino"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; -import { - checkoutResolvedBranch, - type CheckoutExistingBranchResult, - type GitMutationRefreshReason, +import type { + CheckoutExistingBranchResult, + GitMutationRefreshReason, } from "../../../utils/checkout-git.js"; -import { runGitCommand } from "../../../utils/run-git-command.js"; -import type { WorkspaceGitService } from "../../workspace-git-service.js"; +import type { WorkspaceGitService, WorkspaceGitWorkspace } from "../../workspace-git-service.js"; import { assertSafeGitRef as assertWorktreeSafeGitRef } from "../../worktree-session.js"; /** @@ -21,6 +19,8 @@ import { assertSafeGitRef as assertWorktreeSafeGitRef } from "../../worktree-ses * session as loose callbacks. */ export interface GitMutationService { + bind(workspaceGit: WorkspaceGitWorkspace): BoundGitMutation; + /** Explicit compatibility path for callers that do not own a workspace record yet. */ checkoutExistingBranch(cwd: string, branch: string): Promise; createBranchFromBase(params: { cwd: string; @@ -34,9 +34,20 @@ export interface GitMutationService { ): Promise; } +export interface BoundGitMutation { + checkoutExistingBranch(branch: string): Promise; + createBranchFromBase(params: { baseBranch: string; newBranchName: string }): Promise; + notify(reason: GitMutationRefreshReason, options?: { invalidateForge?: boolean }): Promise; +} + type GitMutationGitSource = Pick< WorkspaceGitService, - "validateBranchRef" | "getSnapshot" | "hasLocalBranch" | "invalidateForge" + | "validateBranchRef" + | "getSnapshot" + | "hasLocalBranch" + | "invalidateForge" + | "switchBranch" + | "createBranch" >; export function createGitMutationService(deps: { @@ -45,6 +56,72 @@ export function createGitMutationService(deps: { }): GitMutationService { const { workspaceGitService, logger } = deps; + function bind(workspaceGit: WorkspaceGitWorkspace): BoundGitMutation { + async function isBoundWorkingTreeDirty(): Promise { + try { + const snapshot = await workspaceGit.getSnapshot(); + return snapshot.git.isDirty === true; + } catch (error) { + throw new Error( + `Unable to inspect git status for ${workspaceGit.cwd}: ${getErrorMessage(error)}`, + { cause: error }, + ); + } + } + + async function ensureBoundWorkingTreeClean(): Promise { + if (await isBoundWorkingTreeDirty()) { + throw new Error( + "Working directory has uncommitted changes. Commit or stash before switching branches.", + ); + } + } + + async function notify( + reason: GitMutationRefreshReason, + options?: { invalidateForge?: boolean }, + ): Promise { + if (options?.invalidateForge) { + workspaceGit.invalidateForge(); + } + try { + await workspaceGit.getSnapshot({ force: true, reason }); + } catch (error) { + logger.warn( + { err: error, cwd: workspaceGit.cwd, reason }, + "Failed to force-refresh workspace git snapshot after mutation", + ); + } + } + + return { + async checkoutExistingBranch(branch) { + assertSafeGitRef(branch, "branch"); + const resolution = await workspaceGit.validateBranchRef(branch); + if (resolution.kind === "not-found") throw new Error(`Branch not found: ${branch}`); + await ensureBoundWorkingTreeClean(); + const result = await workspaceGit.switchBranch(branch); + await notify("switch-branch", { invalidateForge: true }); + return result; + }, + async createBranchFromBase({ baseBranch, newBranchName }) { + assertSafeGitRef(baseBranch, "base branch"); + assertSafeGitRef(newBranchName, "new branch"); + const baseResolution = await workspaceGit.validateBranchRef(baseBranch); + if (baseResolution.kind === "not-found") { + throw new Error(`Base branch not found: ${baseBranch}`); + } + if (await workspaceGit.hasLocalBranch(newBranchName)) { + throw new Error(`Branch already exists: ${newBranchName}`); + } + await ensureBoundWorkingTreeClean(); + await workspaceGit.createBranch({ branch: newBranchName, baseRef: baseBranch }); + await notify("create-branch"); + }, + notify, + }; + } + function assertSafeGitRef(ref: string, label: string): void { if (!/^[A-Za-z0-9._/-]+$/.test(ref)) { throw new Error(`Invalid ${label}: ${ref}`); @@ -91,6 +168,7 @@ export function createGitMutationService(deps: { } return { + bind, async checkoutExistingBranch(cwd, branch) { assertSafeGitRef(branch, "branch"); const resolution = await workspaceGitService.validateBranchRef(cwd, branch); @@ -98,7 +176,7 @@ export function createGitMutationService(deps: { throw new Error(`Branch not found: ${branch}`); } await ensureCleanWorkingTree(cwd); - const result = await checkoutResolvedBranch({ cwd, resolution }); + const result = await workspaceGitService.switchBranch(cwd, branch); await notifyGitMutation(cwd, "switch-branch", { invalidateForge: true }); return result; }, @@ -118,9 +196,9 @@ export function createGitMutationService(deps: { } await ensureCleanWorkingTree(cwd); - await runGitCommand(["checkout", "-b", newBranchName, baseBranch], { - cwd, - timeout: 120_000, + await workspaceGitService.createBranch(cwd, { + branch: newBranchName, + baseRef: baseBranch, }); await notifyGitMutation(cwd, "create-branch"); }, diff --git a/packages/server/src/server/session/provider/provider-catalog-session.test.ts b/packages/server/src/server/session/provider/provider-catalog-session.test.ts index 63000ca0d0..dd5dde4539 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.test.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.test.ts @@ -12,6 +12,13 @@ import { type ProviderSnapshotManager, } from "../../agent/provider-snapshot-manager.js"; import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js"; +import type { + AgentClient, + AgentSessionConfig, + FetchCatalogOptions, +} from "../../agent/agent-sdk-types.js"; +import { ProviderSnapshotManager as RealProviderSnapshotManager } from "../../agent/provider-snapshot-manager.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; import type { ProviderUsageService } from "../../../services/quota-fetcher/service.js"; import { expandProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; @@ -56,12 +63,19 @@ function makeSubsystem(options: MakeOptions = {}) { listDraftFeatures: async () => [], ...options.host, }; + const snapshot = options.snapshot ?? {}; const providerSnapshotManager = createStub({ + readSnapshot: async ({ cwd, workspaceId }) => { + const getSnapshot = snapshot.getSnapshot as + | ((snapshotCwd?: string, selectedWorkspaceId?: string) => ProviderSnapshotEntry[]) + | undefined; + return getSnapshot?.(cwd ?? undefined, workspaceId) ?? []; + }, on: (_event: string, handler: SnapshotChangeHandler) => { changeHandler = handler; }, off: () => {}, - ...options.snapshot, + ...snapshot, }); const subsystem = new ProviderCatalogSession({ host, @@ -80,6 +94,65 @@ function makeSubsystem(options: MakeOptions = {}) { } describe("ProviderCatalogSession", () => { + it("fails a fresh selected snapshot closed when its runtime capability cannot bind", async () => { + const hostAvailabilityCalls: Array = []; + const codex = { + provider: "codex", + capabilities: {}, + async isAvailable(options?: FetchCatalogOptions) { + hostAvailabilityCalls.push(options); + return true; + }, + async fetchCatalog() { + return { models: [], modes: [] }; + }, + } as unknown as AgentClient; + const manager = new RealProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { codex }, + resolveProviderWorkspace: async () => { + throw new Error("selected runtime is unavailable"); + }, + }); + const emitted: SessionOutboundMessage[] = []; + const subsystem = new ProviderCatalogSession({ + host: { + emit: (message) => emitted.push(message), + isProviderVisibleToClient: () => true, + supportsCustomModeIcons: () => true, + supportsCompactProviderSnapshots: () => false, + listProviderAvailability: async () => [], + listDraftFeatures: async (_config: AgentSessionConfig) => [], + }, + providerSnapshotManager: manager, + providerUsageService: createStub({}), + logger: createTestLogger(), + }); + + try { + await subsystem.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "selected-first-get", + cwd: "/same-host-cwd", + workspaceId: "selected-docker", + }); + + expect( + findByType(emitted, "get_providers_snapshot_response")?.payload.entries.find( + (entry) => entry.provider === "codex", + ), + ).toMatchObject({ provider: "codex", status: "error" }); + expect(hostAvailabilityCalls).toEqual([]); + } finally { + manager.destroy(); + } + }); it("PUSH gates invisible providers and downgrades unknown mode icons for legacy clients", () => { const { subsystem, emitted, pushSnapshotChange } = makeSubsystem({ visibleProviders: new Set(["codex"]), diff --git a/packages/server/src/server/session/provider/provider-catalog-session.ts b/packages/server/src/server/session/provider/provider-catalog-session.ts index ed7509a916..9ea06d2d2e 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.ts @@ -93,7 +93,11 @@ export class ProviderCatalogSession { } start(): void { - const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => { + const handleProviderSnapshotChange = ( + entries: ProviderSnapshotEntry[], + cwd: string, + workspaceId?: string, + ) => { // COMPAT(providersSnapshot): keep provider visibility gating for older clients. const visibleEntries = entries.filter((entry) => this.host.isProviderVisibleToClient(entry.provider), @@ -106,6 +110,7 @@ export class ProviderCatalogSession { type: "providers_snapshot_update", payload: { ...(snapshotCwd ? { cwd: snapshotCwd } : {}), + ...(workspaceId ? { workspaceId } : {}), entries: [], ...encoded, generatedAt: new Date().toISOString(), @@ -117,6 +122,7 @@ export class ProviderCatalogSession { type: "providers_snapshot_update", payload: { ...(snapshotCwd ? { cwd: snapshotCwd } : {}), + ...(workspaceId ? { workspaceId } : {}), entries: clientEntries, generatedAt: new Date().toISOString(), }, @@ -393,9 +399,12 @@ export class ProviderCatalogSession { ): Promise { // COMPAT(providersSnapshot): keep legacy provider-list RPCs alongside snapshot flow. const snapshotCwd = msg.cwd?.trim() ? resolveSnapshotCwd(expandTilde(msg.cwd)) : undefined; - const entries = this.providerSnapshotManager - .getSnapshot(snapshotCwd) - .filter((entry) => this.host.isProviderVisibleToClient(entry.provider)); + const entries = ( + await this.providerSnapshotManager.readSnapshot({ + cwd: snapshotCwd, + workspaceId: msg.workspaceId, + }) + ).filter((entry) => this.host.isProviderVisibleToClient(entry.provider)); const clientEntries = this.downgradeEntryModesForClient(entries); if (this.host.supportsCompactProviderSnapshots()) { @@ -433,6 +442,7 @@ export class ProviderCatalogSession { if (msg.cwd) { await this.providerSnapshotManager.refreshSnapshotForCwd({ cwd: expandTilde(msg.cwd), + workspaceId: msg.workspaceId, providers: msg.providers, }); } else { diff --git a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts index 1eb08a7022..eeda4bbeb5 100644 --- a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts +++ b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts @@ -5,7 +5,7 @@ import type { WorkspaceDescriptorPayload } from "../../messages.js"; import type { WorkspaceGitListener, WorkspaceGitRuntimeSnapshot, - WorkspaceGitService, + WorkspaceGitWorkspace, } from "../../workspace-git-service.js"; import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; import { createWorkspaceGitObserverService } from "./workspace-git-observer-service.js"; @@ -54,33 +54,64 @@ function flushMicrotasks(): Promise { return new Promise((done) => setImmediate(done)); } -function buildHarness(opts: { emitCwdRejects?: boolean } = {}) { +function buildHarness( + opts: { + emitWorkspaceIdRejects?: boolean; + distinctWorkspaceBindings?: boolean; + selectedWorkspaceIds?: ReadonlySet; + } = {}, +) { const listeners = new Map(); const registerCalls: string[] = []; const unsubscribeCalls: string[] = []; - const emitCwdCalls: string[] = []; const emitWorkspaceIdCalls: string[] = []; - const statusCalls: Array<{ cwd: string; branch: string | null }> = []; + const statusCalls: Array<{ + workspaceId: string | undefined; + cwd: string; + branch: string | null; + }> = []; const branchChanges: Array<[string, string | null, string | null]> = []; const warnCalls: unknown[][] = []; const describeCalls: PersistedWorkspaceRecord[] = []; let describeResult: WorkspaceDescriptorPayload | null = null; - const workspaceGitService: Pick = { - registerWorkspace({ cwd }, listener) { - registerCalls.push(cwd); - listeners.set(cwd, listener); + const workspaces = new Map(); + function resolveWorkspaceGit(workspaceId: string, cwd: string) { + const selected = opts.distinctWorkspaceBindings || opts.selectedWorkspaceIds?.has(workspaceId); + const key = selected ? workspaceId : cwd; + let workspaceGit = workspaces.get(key); + if (workspaceGit) { return { - unsubscribe() { - unsubscribeCalls.push(cwd); - listeners.delete(cwd); - }, + address: selected + ? { kind: "selected" as const, workspaceId, cwd } + : { kind: "legacy" as const, cwd }, + workspaceGit, }; - }, - }; + } + workspaceGit = { + cwd, + register(listener) { + registerCalls.push(cwd); + listeners.set(key, listener); + return { + unsubscribe() { + unsubscribeCalls.push(cwd); + listeners.delete(key); + }, + }; + }, + } as WorkspaceGitWorkspace; + workspaces.set(key, workspaceGit); + return { + address: selected + ? { kind: "selected" as const, workspaceId, cwd } + : { kind: "legacy" as const, cwd }, + workspaceGit, + }; + } const service = createWorkspaceGitObserverService({ - workspaceGitService, + resolveWorkspaceGit, describeWorkspaceRecordWithGitData: async (workspace) => { describeCalls.push(workspace); if (!describeResult) { @@ -88,17 +119,12 @@ function buildHarness(opts: { emitCwdRejects?: boolean } = {}) { } return describeResult; }, - emitWorkspaceUpdateForCwd: async (cwd) => { - emitCwdCalls.push(cwd); - if (opts.emitCwdRejects) { - throw new Error("emit boom"); - } - }, emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => { emitWorkspaceIdCalls.push(workspaceId); + if (opts.emitWorkspaceIdRejects) throw new Error("emit boom"); }, - emitStatusUpdate: (cwd, snapshot) => { - statusCalls.push({ cwd, branch: snapshot.git.currentBranch ?? null }); + emitStatusUpdate: (workspaceId, cwd, snapshot) => { + statusCalls.push({ workspaceId, cwd, branch: snapshot.git.currentBranch ?? null }); }, onBranchChanged: (workspaceId, oldBranch, newBranch) => { branchChanges.push([workspaceId, oldBranch, newBranch]); @@ -114,12 +140,18 @@ function buildHarness(opts: { emitCwdRejects?: boolean } = {}) { listener(makeSnapshot(cwd, branch)); } + function emitWorkspaceSnapshot(workspaceId: string, cwd: string, branch: string | null): void { + const listener = listeners.get(workspaceId); + if (!listener) throw new Error(`no listener registered for ${workspaceId}`); + listener(makeSnapshot(cwd, branch)); + } + return { service, emitSnapshot, + emitWorkspaceSnapshot, registerCalls, unsubscribeCalls, - emitCwdCalls, emitWorkspaceIdCalls, statusCalls, branchChanges, @@ -245,8 +277,8 @@ describe("git snapshot listener", () => { h.emitSnapshot(WS1, "feature"); await flushMicrotasks(); expect(h.branchChanges).toEqual([["ws1", null, "feature"]]); - expect(h.emitCwdCalls).toEqual([WS1]); - expect(h.statusCalls).toEqual([{ cwd: WS1, branch: "feature" }]); + expect(h.emitWorkspaceIdCalls).toEqual(["ws1"]); + expect(h.statusCalls).toEqual([{ workspaceId: "ws1", cwd: WS1, branch: "feature" }]); }); test("does not re-fire onBranchChanged when the branch is unchanged", () => { @@ -258,13 +290,40 @@ describe("git snapshot listener", () => { }); test("logs and swallows an emit failure without skipping the status update", async () => { - const h = buildHarness({ emitCwdRejects: true }); + const h = buildHarness({ emitWorkspaceIdRejects: true }); h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]); expect(() => h.emitSnapshot(WS1, "feature")).not.toThrow(); - expect(h.statusCalls).toEqual([{ cwd: WS1, branch: "feature" }]); + expect(h.statusCalls).toEqual([{ workspaceId: "ws1", cwd: WS1, branch: "feature" }]); await flushMicrotasks(); expect(h.warnCalls).toHaveLength(1); }); + + test("a selected snapshot updates only its owning same-cwd workspace", async () => { + const h = buildHarness({ distinctWorkspaceBindings: true }); + h.service.syncObservers([ + makeDescriptor({ id: "selected-a", workspaceDirectory: WS1 }), + makeDescriptor({ id: "selected-b", workspaceDirectory: WS1 }), + ]); + + h.emitWorkspaceSnapshot("selected-a", WS1, "branch-a"); + await flushMicrotasks(); + + expect(h.branchChanges).toEqual([["selected-a", null, "branch-a"]]); + expect(h.emitWorkspaceIdCalls).toEqual(["selected-a"]); + expect(h.statusCalls).toEqual([{ workspaceId: "selected-a", cwd: WS1, branch: "branch-a" }]); + }); + + test("a legacy cwd branch event does not cross into a selected same-cwd workspace", () => { + const h = buildHarness({ selectedWorkspaceIds: new Set(["selected"]) }); + h.service.syncObservers([ + makeDescriptor({ id: "legacy", workspaceDirectory: WS1 }), + makeDescriptor({ id: "selected", workspaceDirectory: WS1 }), + ]); + + h.service.handleBranchSnapshot({ cwd: WS1 }, "legacy-next"); + + expect(h.branchChanges).toEqual([["legacy", null, "legacy-next"]]); + }); }); describe("shouldSkipUpdate", () => { diff --git a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts index dfc87b312b..c1c75338e7 100644 --- a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts +++ b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts @@ -3,9 +3,10 @@ import type pino from "pino"; import type { WorkspaceDescriptorPayload } from "../../messages.js"; import type { WorkspaceGitRuntimeSnapshot, - WorkspaceGitService, + WorkspaceGitWorkspace, } from "../../workspace-git-service.js"; import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; +import type { WorkspaceGitAddress } from "../../workspace-git-directory.js"; const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__"; @@ -15,6 +16,8 @@ interface WorkspaceGitWatchTarget { interface WorkspaceGitWatchState { cwd: string; + address: WorkspaceGitAddress; + workspaceGit: WorkspaceGitWorkspace; latestDescriptorStateKey: string | null; lastBranchName: string | null; } @@ -29,9 +32,8 @@ export interface WorkspaceGitObserverMetrics { * Observes a workspace's git state on disk (via WorkspaceGitService) and drives the * live update fan-out: branch-change notifications, workspace-card refreshes, and * checkout status updates. It owns the per-cwd watch targets and the WorkspaceGitService - * subscription handles. Filesystem subscriptions are keyed by cwd while descriptor and - * branch state remain keyed by workspace id, so same-directory workspace records share one - * watch without sharing identity or teardown lifetime. + * subscription handles. Selected subscriptions are keyed by their bound capability; only + * explicitly legacy bindings may share cwd fan-out. * * Branch changes reach `onBranchChanged` from two paths that share `lastBranchName`: the * on-disk snapshot listener (handleBranchSnapshot) and the workspace-emit loop's Git runtime @@ -45,20 +47,26 @@ export interface WorkspaceGitObserverService { // for this workspace, and otherwise advances the recorded state key as a side effect. shouldSkipUpdate(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): boolean; recordDescriptorState(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): void; - handleBranchSnapshot(cwd: string, branchName: string | null): void; + handleBranchSnapshot(address: WorkspaceGitAddress, branchName: string | null): void; getMetrics(): WorkspaceGitObserverMetrics; removeForWorkspaceId(workspaceId: string): void; dispose(): void; } export function createWorkspaceGitObserverService(deps: { - workspaceGitService: Pick; + resolveWorkspaceGit: ( + workspaceId: string, + cwd: string, + ) => { address: WorkspaceGitAddress; workspaceGit: WorkspaceGitWorkspace }; describeWorkspaceRecordWithGitData: ( workspace: PersistedWorkspaceRecord, ) => Promise; - emitWorkspaceUpdateForCwd: (cwd: string) => Promise; emitWorkspaceUpdateForWorkspaceId: (workspaceId: string) => Promise; - emitStatusUpdate: (cwd: string, snapshot: WorkspaceGitRuntimeSnapshot) => void; + emitStatusUpdate: ( + workspaceId: string, + cwd: string, + snapshot: WorkspaceGitRuntimeSnapshot, + ) => void; onBranchChanged?: ( workspaceId: string, oldBranch: string | null, @@ -67,18 +75,17 @@ export function createWorkspaceGitObserverService(deps: { logger: pino.Logger; }): WorkspaceGitObserverService { const { - workspaceGitService, + resolveWorkspaceGit, describeWorkspaceRecordWithGitData, - emitWorkspaceUpdateForCwd, emitWorkspaceUpdateForWorkspaceId, emitStatusUpdate, onBranchChanged, logger, } = deps; - const watchTargets = new Map(); + const watchTargets = new Map(); const workspaceStates = new Map(); - const subscriptions = new Map void>(); + const subscriptions = new Map void>(); function descriptorStateKey(workspace: WorkspaceDescriptorPayload | null): string { if (!workspace) { @@ -105,15 +112,14 @@ export function createWorkspaceGitObserverService(deps: { } } - function removeForCwd(cwd: string): void { - const normalizedCwd = resolve(cwd); - const target = watchTargets.get(normalizedCwd); + function removeForWorkspaceGit(workspaceGit: WorkspaceGitWorkspace): void { + const target = watchTargets.get(workspaceGit); for (const workspaceId of target?.workspaceIds ?? []) { workspaceStates.delete(workspaceId); } - watchTargets.delete(normalizedCwd); - subscriptions.get(normalizedCwd)?.(); - subscriptions.delete(normalizedCwd); + watchTargets.delete(workspaceGit); + subscriptions.get(workspaceGit)?.(); + subscriptions.delete(workspaceGit); } function removeForWorkspaceId(workspaceId: string): void { @@ -122,20 +128,26 @@ export function createWorkspaceGitObserverService(deps: { return; } workspaceStates.delete(workspaceId); - const target = watchTargets.get(state.cwd); + const target = watchTargets.get(state.workspaceGit); target?.workspaceIds.delete(workspaceId); if (target?.workspaceIds.size === 0) { - removeForCwd(state.cwd); + removeForWorkspaceGit(state.workspaceGit); } } - function handleBranchSnapshot(cwd: string, branchName: string | null): void { - const target = watchTargets.get(resolve(cwd)); - if (!target) { - return; - } - - for (const workspaceId of target.workspaceIds) { + function handleBranchSnapshot(address: WorkspaceGitAddress, branchName: string | null): void { + const addressedState = + address.kind === "selected" ? workspaceStates.get(address.workspaceId) : null; + const target = addressedState ? watchTargets.get(addressedState.workspaceGit) : null; + const workspaceIds = target + ? [...target.workspaceIds] + : [...workspaceStates] + .filter( + ([, candidate]) => + candidate.address.kind === "legacy" && candidate.cwd === resolve(address.cwd), + ) + .map(([workspaceId]) => workspaceId); + for (const workspaceId of workspaceIds) { const state = workspaceStates.get(workspaceId); if (!state) { continue; @@ -151,8 +163,12 @@ export function createWorkspaceGitObserverService(deps: { function syncObserver(cwd: string, options: { isGit: boolean; workspaceId: string }): void { const normalizedCwd = resolve(cwd); + const { address, workspaceGit } = resolveWorkspaceGit(options.workspaceId, normalizedCwd); const currentState = workspaceStates.get(options.workspaceId); - if (currentState && currentState.cwd !== normalizedCwd) { + if ( + currentState && + (currentState.cwd !== normalizedCwd || currentState.workspaceGit !== workspaceGit) + ) { removeForWorkspaceId(options.workspaceId); } if (!options.isGit) { @@ -160,40 +176,44 @@ export function createWorkspaceGitObserverService(deps: { return; } - const target = watchTargets.get(normalizedCwd) ?? { + const target = watchTargets.get(workspaceGit) ?? { workspaceIds: new Set(), }; - watchTargets.set(normalizedCwd, target); + watchTargets.set(workspaceGit, target); target.workspaceIds.add(options.workspaceId); if (!workspaceStates.has(options.workspaceId)) { workspaceStates.set(options.workspaceId, { cwd: normalizedCwd, + address, + workspaceGit, latestDescriptorStateKey: null, lastBranchName: null, }); } - if (subscriptions.has(normalizedCwd)) { + if (subscriptions.has(workspaceGit)) { return; } - let subscription: ReturnType; + let subscription: ReturnType; try { - subscription = workspaceGitService.registerWorkspace({ cwd: normalizedCwd }, (snapshot) => { - handleBranchSnapshot(normalizedCwd, snapshot.git.currentBranch ?? null); - void emitWorkspaceUpdateForCwd(normalizedCwd).catch((error) => { - logger.warn( - { err: error, cwd: normalizedCwd }, - "Failed to emit workspace update after git branch snapshot", - ); - }); - emitStatusUpdate(normalizedCwd, snapshot); + subscription = workspaceGit.register((snapshot) => { + handleBranchSnapshot(address, snapshot.git.currentBranch ?? null); + for (const workspaceId of target.workspaceIds) { + void emitWorkspaceUpdateForWorkspaceId(workspaceId).catch((error) => { + logger.warn( + { err: error, cwd: normalizedCwd, workspaceId }, + "Failed to emit workspace update after git branch snapshot", + ); + }); + emitStatusUpdate(workspaceId, normalizedCwd, snapshot); + } }); } catch (error) { removeForWorkspaceId(options.workspaceId); throw error; } - subscriptions.set(normalizedCwd, subscription.unsubscribe); + subscriptions.set(workspaceGit, subscription.unsubscribe); } function syncObservers(workspaces: Iterable): void { diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index a97e82f85d..6c3beb88be 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -506,6 +506,30 @@ test("directory creation persists the live branch and a trimmed title", async () expect(workspace).toMatchObject({ branch: "main", title: "Focused work" }); }); +test("selected-runtime directory creation stays provisional until runtime placement is persisted", async () => { + const repo = path.join(tmpDir, "repo"); + gitRoots.add(repo); + + const workspace = await provisioning.createWorkspaceForDirectory(repo, null, undefined, { + runtimeId: "docker", + }); + + expect(await workspaceRegistry.get(workspace.workspaceId)).toMatchObject({ + cwd: repo, + runtime: { runtimeId: "docker" }, + }); + expect(await workspaceRegistry.list()).toEqual([]); + + await workspaceRegistry.update(workspace.workspaceId, (record) => ({ + ...record, + cwd: "/workspace", + })); + + expect(await workspaceRegistry.list()).toEqual([ + expect.objectContaining({ workspaceId: workspace.workspaceId, cwd: "/workspace" }), + ]); +}); + test("createWorkspaceForDirectory honors an explicit active project without cwd containment", async () => { const project = await projectRegistry.getOrCreateActiveByRoot({ rootPath: path.join(tmpDir, "elsewhere"), 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 90b0c7c128..6de5bd2b1a 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 @@ -13,6 +13,7 @@ import { type WorkspaceRegistry, } from "../../workspace-registry.js"; import type { WorkspaceGitService } from "../../workspace-git-service.js"; +import { createWorkspaceGitDirectory } from "../../workspace-git-directory.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; import { deriveProjectKey } from "../../project-key.js"; import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js"; @@ -46,6 +47,16 @@ export interface CreateWorktreeWorkspaceInput { expectsInitialAgent?: boolean; } +export interface ReserveRuntimeWorktreeWorkspaceInput { + sourceCwd: string; + projectId?: string; + repoRoot: string; + branch: string | null; + baseBranch: string | null; + title: string | null; + expectsInitialAgent?: boolean; +} + export interface WorkspaceProvisioningService { runInImportWorkspace( input: ImportWorkspaceInput, @@ -57,11 +68,14 @@ export interface WorkspaceProvisioningService { cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean }, + context?: { expectsInitialAgent?: boolean; runtimeId?: string }, ): Promise; createWorkspaceForWorktree( input: CreateWorktreeWorkspaceInput, ): Promise; + reserveRuntimeWorktreeWorkspace( + input: ReserveRuntimeWorktreeWorkspaceInput, + ): Promise; findOrCreateProjectForDirectory(cwd: string): Promise; ensureWorkspaceRecordUnarchived( workspace: PersistedWorkspaceRecord, @@ -88,10 +102,17 @@ export function createWorkspaceProvisioningService(deps: { serverId?: string; workspaceRegistry: WorkspaceRegistry; projectRegistry: ProjectRegistry; - workspaceGitService: Pick; + workspaceGitService: Pick< + WorkspaceGitService, + "bindLegacy" | "bindWorkspace" | "getCheckout" | "getSnapshot" | "peekSnapshot" + >; logger: Logger; }): WorkspaceProvisioningService { const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps; + const workspaceGitDirectory = createWorkspaceGitDirectory({ + workspaceRegistry, + workspaceGitService, + }); async function runInImportWorkspace( input: ImportWorkspaceInput, @@ -186,7 +207,7 @@ export function createWorkspaceProvisioningService(deps: { cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean }, + context?: { expectsInitialAgent?: boolean; runtimeId?: string }, ): Promise { const normalizedCwd = resolve(cwd); const checkout = await workspaceGitService.getCheckout(normalizedCwd); @@ -200,10 +221,14 @@ export function createWorkspaceProvisioningService(deps: { projectId: project.projectId, ...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }), title: title?.trim() || null, + ...(context?.runtimeId ? { runtime: { runtimeId: context.runtimeId } } : {}), createdAt: timestamp, updatedAt: timestamp, }); - await workspaceRegistry.upsert(workspace, context); + await workspaceRegistry.upsert(workspace, { + ...context, + provisional: context?.runtimeId !== undefined, + }); return workspace; } @@ -241,6 +266,39 @@ export function createWorkspaceProvisioningService(deps: { return workspace; } + async function reserveRuntimeWorktreeWorkspace( + input: ReserveRuntimeWorktreeWorkspaceInput, + ): Promise { + const sourceCwd = resolve(input.sourceCwd); + const repoRoot = resolve(input.repoRoot); + const project = await resolveSourceProjectForWorktree({ + sourceCwd, + projectId: input.projectId, + repoRoot, + }); + const timestamp = new Date().toISOString(); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: generateWorkspaceId(), + projectId: project.projectId, + ...initialWorkspacePlacement({ + source: "runtime_worktree", + cwd: sourceCwd, + branch: input.branch, + baseBranch: input.baseBranch, + mainRepoRoot: repoRoot, + }), + title: input.title, + runtime: { runtimeId: "worktree" }, + createdAt: timestamp, + updatedAt: timestamp, + }); + await workspaceRegistry.upsert(workspace, { + expectsInitialAgent: input.expectsInitialAgent, + provisional: true, + }); + return workspace; + } + async function resolveSourceProjectForWorktree(input: { sourceCwd: string; projectId?: string; @@ -329,7 +387,8 @@ export function createWorkspaceProvisioningService(deps: { if (!workspace.archivedAt) { return workspace.autoArchivedChangeRequestUrl; } - const snapshot = await workspaceGitService.getSnapshot(workspace.cwd, { + if (workspace.runtime?.runtimeId) return workspace.autoArchivedChangeRequestUrl; + const snapshot = await workspaceGitDirectory.bindRecord(workspace).getSnapshot({ force: true, includeForge: true, reason: "workspace-restore-auto-archive-latch", @@ -347,7 +406,7 @@ export function createWorkspaceProvisioningService(deps: { const timestamp = new Date().toISOString(); const checkout = workspace.archivedAt || project.archivedAt - ? await workspaceGitService.getCheckout(workspace.cwd) + ? await workspaceGitDirectory.bindRecord(workspace).getCheckout() : null; const autoArchivedChangeRequestUrl = await resolveRestoredAutoArchiveChangeRequestUrl(workspace); @@ -369,7 +428,7 @@ export function createWorkspaceProvisioningService(deps: { const projectCheckout = areEquivalentPaths(project.rootPath, workspace.cwd) ? checkout : await workspaceGitService.getCheckout(project.rootPath); - const kind = projectCheckout.isGit ? "git" : "non_git"; + const kind = project.source || projectCheckout.isGit ? "git" : "non_git"; const projectKey = deriveProjectKey({ rootPath: project.rootPath, remoteUrl: projectCheckout.remoteUrl, @@ -395,7 +454,7 @@ export function createWorkspaceProvisioningService(deps: { async function refreshWorkspaceRecord( workspace: PersistedWorkspaceRecord, ): Promise { - const checkout = await workspaceGitService.getCheckout(workspace.cwd); + const checkout = await workspaceGitDirectory.bindRecord(workspace).getCheckout(); const project = await projectRegistry.get(workspace.projectId); if (project && !project.archivedAt) { await refreshProjectKind(project, workspace.cwd, checkout); @@ -419,7 +478,8 @@ export function createWorkspaceProvisioningService(deps: { workspaceCwd && workspaceCheckout && areEquivalentPaths(project.rootPath, workspaceCwd) ? workspaceCheckout : await workspaceGitService.getCheckout(project.rootPath); - const kind: PersistedProjectRecord["kind"] = projectCheckout.isGit ? "git" : "non_git"; + const kind: PersistedProjectRecord["kind"] = + project.source || projectCheckout.isGit ? "git" : "non_git"; const projectKey = deriveProjectKey({ rootPath: project.rootPath, remoteUrl: projectCheckout.remoteUrl, @@ -444,6 +504,7 @@ export function createWorkspaceProvisioningService(deps: { resolveOrCreateWorkspaceIdForCreateAgent, createWorkspaceForDirectory, createWorkspaceForWorktree, + reserveRuntimeWorktreeWorkspace, findOrCreateProjectForDirectory, ensureWorkspaceRecordUnarchived, }; diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts index 91631a5631..cf188afc2f 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts @@ -70,6 +70,7 @@ function createHarness(input?: { directories?: string[]; paseoHome?: string; worktreesRoot?: string; + inspectRuntime?: (workspaceId: string) => Promise<"missing" | "paused" | "ready" | "error">; }) { const workspace = input?.workspace === undefined ? createWorkspace() : input.workspace; const project = input?.project === undefined ? createProject() : input.project; @@ -85,6 +86,7 @@ function createHarness(input?: { unarchiveWorkspace: async (record) => { unarchived.push(record.workspaceId); }, + inspectRuntime: input?.inspectRuntime, }); return { service, unarchived }; } @@ -117,6 +119,42 @@ describe("workspace recovery", () => { expect(unarchived).toEqual([workspace.workspaceId]); }); + test("unarchives a ready archived runtime without presenting it as a restore", async () => { + const workspace = createWorkspace({ runtime: { runtimeId: "worktree" } }); + const { service, unarchived } = createHarness({ + workspace, + inspectRuntime: async () => "ready", + }); + + await expect(service.inspect(workspace.workspaceId)).resolves.toMatchObject({ + kind: "recoverable", + action: "unarchive", + }); + await expect(service.restore(workspace.workspaceId)).resolves.toEqual({ + workspaceId: workspace.workspaceId, + action: "unarchive", + }); + expect(unarchived).toEqual([workspace.workspaceId]); + }); + + test("restores a paused archived runtime through its runtime-aware unarchive owner", async () => { + const workspace = createWorkspace({ runtime: { runtimeId: "worktree" } }); + const { service, unarchived } = createHarness({ + workspace, + inspectRuntime: async () => "paused", + }); + + await expect(service.inspect(workspace.workspaceId)).resolves.toMatchObject({ + kind: "recoverable", + action: "restore", + }); + await expect(service.restore(workspace.workspaceId)).resolves.toEqual({ + workspaceId: workspace.workspaceId, + action: "restore", + }); + expect(unarchived).toEqual([workspace.workspaceId]); + }); + test("does not offer recovery for a missing non-worktree directory", async () => { const workspace = createWorkspace({ kind: "directory", branch: null }); const { service } = createHarness({ workspace }); diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts index 1d98a9128c..dd5536c9ec 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts @@ -54,6 +54,11 @@ type RecoveryPlan = state: Extract; workspace: PersistedWorkspaceRecord; sourceRepoRoot: string; + } + | { + kind: "runtime-restore"; + state: Extract; + workspace: PersistedWorkspaceRecord; }; type UnavailableRecoveryState = Extract; @@ -65,6 +70,7 @@ export function createWorkspaceRecoveryService(deps: { getProject: (projectId: string) => Promise; isDirectory: (path: string) => Promise; unarchiveWorkspace: (workspace: PersistedWorkspaceRecord) => Promise; + inspectRuntime?: (workspaceId: string) => Promise<"missing" | "paused" | "ready" | "error">; }): WorkspaceRecoveryService { async function resolveRecovery( workspaceId: string, @@ -97,6 +103,30 @@ export function createWorkspaceRecoveryService(deps: { }; } + if (workspace.runtime) { + if (!deps.inspectRuntime) { + return { + kind: "unavailable", + workspaceId, + reason: "workspace_directory_missing", + message: "The workspace runtime is not available on this host.", + }; + } + const runtimeStatus = await deps.inspectRuntime(workspaceId); + if (runtimeStatus === "ready") { + return createRecoveryPlan({ action: "unarchive", workspace }); + } + if (runtimeStatus === "paused") { + return createRecoveryPlan({ action: "runtime-restore", workspace }); + } + return { + kind: "unavailable", + workspaceId, + reason: "workspace_directory_missing", + message: `The archived workspace runtime is ${runtimeStatus}.`, + }; + } + if (await deps.isDirectory(workspace.cwd)) { return createRecoveryPlan({ action: "unarchive", workspace }); } @@ -150,7 +180,7 @@ export function createWorkspaceRecoveryService(deps: { await recreateArchivedWorktree(resolved.workspace, resolved.sourceRepoRoot); } await deps.unarchiveWorkspace(resolved.workspace); - return { workspaceId, action: resolved.kind }; + return { workspaceId, action: resolved.state.action }; } async function recreateArchivedWorktree( @@ -237,7 +267,8 @@ export function createWorkspaceRecoveryService(deps: { function createRecoveryPlan( input: | { action: "unarchive"; workspace: PersistedWorkspaceRecord } - | { action: "restore"; workspace: PersistedWorkspaceRecord; sourceRepoRoot: string }, + | { action: "restore"; workspace: PersistedWorkspaceRecord; sourceRepoRoot: string } + | { action: "runtime-restore"; workspace: PersistedWorkspaceRecord }, ): RecoveryPlan { const state = { kind: "recoverable" as const, @@ -253,6 +284,13 @@ function createRecoveryPlan( sourceRepoRoot: input.sourceRepoRoot, }; } + if (input.action === "runtime-restore") { + return { + kind: input.action, + state: { ...state, action: "restore" }, + workspace: input.workspace, + }; + } return { kind: input.action, state: { diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index c377639747..6474c2b727 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts @@ -18,9 +18,13 @@ import type { SpawnWorkspaceScriptOptions, WorktreeScriptResult, } from "../../worktree-bootstrap.js"; -import type { WorkspaceGitService } from "../../workspace-git-service.js"; +import type { WorkspaceGitWorkspace } from "../../workspace-git-service.js"; import { createWorkspaceScriptsService } from "./workspace-scripts-service.js"; import { deriveProjectServiceSlug } from "../../workspace-git-metadata.js"; +import { + createWorkspaceRuntimeService, + type WorkspaceRuntimeService, +} from "../../workspace-runtime/index.js"; // The production module reads only WorkspaceGitService.{peekSnapshot,getProjectSlug}, // WorkspaceRegistry.get, and forwards the launcher + opaque managers to the injected @@ -76,7 +80,8 @@ interface BuildOptions { workspace?: PersistedWorkspaceRecord | null; project?: PersistedProjectRecord | null; spawnThrows?: string; - gitService?: Pick; + gitService?: Pick; + workspaceRuntime?: WorkspaceRuntimeService; } function buildService(options: BuildOptions = {}) { @@ -100,7 +105,10 @@ function buildService(options: BuildOptions = {}) { options.terminalManager === undefined ? availableTerminalManager : options.terminalManager, workspaceRegistry: fakeWorkspaceRegistry(workspace), projectRegistry: fakeProjectRegistry(options.project ?? null), - workspaceGitService: options.gitService ?? fakeGitService(), + workspaceGitDirectory: { + bindRecord: () => options.gitService ?? fakeGitService(), + }, + workspaceRuntime: options.workspaceRuntime, getDaemonTcpPort: () => 6767, getDaemonTcpHost: () => "127.0.0.1", serviceProxyPublicBaseUrl: null, @@ -146,14 +154,20 @@ describe("buildSnapshot", () => { test("returns no scripts when the service proxy is unavailable", async () => { const { service } = buildService({ serviceProxy: null }); expect( - service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord), + await service.buildSnapshot({ + workspaceId: "ws-1", + cwd: "/tmp/repo", + } as PersistedWorkspaceRecord), ).toEqual([]); }); test("returns no scripts when the runtime store is unavailable", async () => { const { service } = buildService({ scriptRuntimeStore: null }); expect( - service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord), + await service.buildSnapshot({ + workspaceId: "ws-1", + cwd: "/tmp/repo", + } as PersistedWorkspaceRecord), ).toEqual([]); }); @@ -162,10 +176,57 @@ describe("buildSnapshot", () => { tempDirs.push(dir); const { service } = buildService(); expect( - service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord), + await service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord), ).toEqual([]); }); + test("discovers paseo.json inside a selected workspace runtime", async () => { + const dir = mkdtempSync(join(tmpdir(), "workspace-scripts-runtime-")); + const paseoHome = mkdtempSync(join(tmpdir(), "workspace-scripts-runtime-home-")); + tempDirs.push(dir, paseoHome); + writeFileSync( + join(dir, "paseo.json"), + JSON.stringify({ scripts: { app: { command: "npm run app" } } }), + ); + const runtimeIds = new Map(); + const workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome, + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => { + runtimeIds.set(workspaceId, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, + }); + await workspaceRuntime.create({ + workspaceId: "ws-runtime-config", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: dir } }, + placement: { kind: "existing" }, + }); + const workspace = { + workspaceId: "ws-runtime-config", + projectId: "project", + cwd: dir, + runtime: { runtimeId: "local" }, + } as PersistedWorkspaceRecord; + const { service, spawnCalls } = buildService({ workspace, workspaceRuntime }); + + await expect(service.buildSnapshot(workspace)).resolves.toEqual([ + expect.objectContaining({ scriptName: "app", type: "script" }), + ]); + await service.launch({ workspaceId: workspace.workspaceId, scriptName: "app" }); + expect(spawnCalls[0]).toMatchObject({ + workspaceId: workspace.workspaceId, + runtimeCwd: dir, + paseoConfig: { scripts: { app: { command: "npm run app" } } }, + }); + expect(spawnCalls[0]?.runtime).toBeDefined(); + await workspaceRuntime.destroy(workspace.workspaceId); + }); + test("projects service hostnames without a Git snapshot", async () => { const directory = mkdtempSync(join(tmpdir(), "workspace-scripts-")); tempDirs.push(directory); @@ -197,7 +258,7 @@ describe("buildSnapshot", () => { gitService: { peekSnapshot: () => undefined }, }); - expect(service.buildSnapshot(workspace, project)[0]?.hostname).toBe( + expect((await service.buildSnapshot(workspace, project))[0]?.hostname).toBe( serviceProxy.projectWorkspaceService({ projectSlug: deriveProjectServiceSlug(project), branchName: workspace.branch, @@ -416,7 +477,7 @@ describe("start", () => { const serviceProxy = createServiceProxySubsystem({ logger }); const { service, spawnCalls } = buildService({ workspace, project, serviceProxy }); - const snapshot = service.buildSnapshot(workspace, project); + const snapshot = await service.buildSnapshot(workspace, project); await service.start({ ...request, workspaceId: workspace.workspaceId }); const started = spawnCalls[0]!; diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index b43e13548b..6d0bc3f09e 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -9,7 +9,7 @@ import type { TerminalManager } from "../../../terminal/terminal-manager.js"; import type { ServiceProxySubsystem } from "../../service-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js"; import type { ScriptHealthState } from "../../script-health-monitor.js"; -import type { WorkspaceGitService } from "../../workspace-git-service.js"; +import type { WorkspaceGitDirectory } from "../../workspace-git-directory.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord, @@ -26,6 +26,10 @@ import { } from "../../script-status-projection.js"; import { deriveProjectServiceSlug, deriveProjectSlug } from "../../workspace-git-metadata.js"; import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema"; +import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema"; +import type { WorkspaceRuntimeService } from "../../workspace-runtime/index.js"; +import { parsePaseoConfigContentsOrThrow } from "../../../utils/worktree.js"; +import { resolvePaseoConfigPath } from "../../../utils/paseo-config-file.js"; type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"]; @@ -42,7 +46,7 @@ export interface WorkspaceScriptsService { buildSnapshot( workspace: PersistedWorkspaceRecord, project?: PersistedProjectRecord | null, - ): WorkspaceScriptsPayload; + ): Promise; emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise; list(workspaceId: string): Promise; launch(input: { workspaceId: string; scriptName: string }): Promise; @@ -50,7 +54,42 @@ export interface WorkspaceScriptsService { start(request: StartWorkspaceScriptRequest): Promise; } -type WorkspaceScriptsGitSource = Pick; +export async function readWorkspacePaseoConfig(input: { + workspace: PersistedWorkspaceRecord; + workspaceRuntime?: WorkspaceRuntimeService | null; + logger: pino.Logger; +}): Promise { + const { workspace, workspaceRuntime, logger } = input; + if (!workspace.runtime) return readPaseoConfigForProjection(workspace.cwd, logger); + if (!workspaceRuntime) { + throw new Error(`Workspace runtime is not available: ${workspace.workspaceId}`); + } + const files = workspaceRuntime.files(workspace.workspaceId); + const version = await files.stat("paseo.json"); + if (version.status === "missing") return null; + if (version.status === "error") { + logger.warn( + { workspaceId: workspace.workspaceId, error: version.error }, + "Failed to read runtime paseo.json; treating workspace as having no scripts", + ); + return null; + } + try { + const file = await files.read("paseo.json"); + const chunks: Buffer[] = []; + for await (const chunk of file.chunks) chunks.push(Buffer.from(chunk)); + return parsePaseoConfigContentsOrThrow( + Buffer.concat(chunks), + resolvePaseoConfigPath(workspace.cwd), + ); + } catch (error) { + logger.warn( + { workspaceId: workspace.workspaceId, err: error }, + "Failed to parse runtime paseo.json; treating workspace as having no scripts", + ); + return null; + } +} export function createWorkspaceScriptsService(deps: { serviceProxy: ServiceProxySubsystem | null; @@ -58,7 +97,8 @@ export function createWorkspaceScriptsService(deps: { terminalManager: TerminalManager | null; workspaceRegistry: Pick; projectRegistry: Pick; - workspaceGitService: WorkspaceScriptsGitSource; + workspaceGitDirectory: Pick; + workspaceRuntime?: WorkspaceRuntimeService; getDaemonTcpPort: (() => number | null) | null; getDaemonTcpHost: (() => string | null) | null; serviceProxyPublicBaseUrl: string | null; @@ -74,7 +114,8 @@ export function createWorkspaceScriptsService(deps: { terminalManager, workspaceRegistry, projectRegistry, - workspaceGitService, + workspaceGitDirectory, + workspaceRuntime, getDaemonTcpPort, getDaemonTcpHost, serviceProxyPublicBaseUrl, @@ -89,7 +130,7 @@ export function createWorkspaceScriptsService(deps: { workspace: PersistedWorkspaceRecord, project: { projectId: string; rootPath: string } | null, ) { - const snapshot = workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = workspaceGitDirectory.bindRecord(workspace).peekSnapshot(); const currentBranch = snapshot?.git.currentBranch ?? workspace.branch ?? null; if (project) { return { @@ -106,17 +147,17 @@ export function createWorkspaceScriptsService(deps: { }; } - function buildSnapshot( + async function buildSnapshot( workspace: PersistedWorkspaceRecord, project: PersistedProjectRecord | null = null, - ): WorkspaceScriptsPayload { + ): Promise { if (!serviceProxy || !scriptRuntimeStore) { return []; } return buildWorkspaceScriptPayloads({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.cwd, - paseoConfig: readPaseoConfigForProjection(workspace.cwd, logger), + paseoConfig: await readWorkspacePaseoConfig({ workspace, workspaceRuntime, logger }), serviceProxy, runtimeStore: scriptRuntimeStore, daemonPort: getDaemonTcpPort?.() ?? null, @@ -133,7 +174,7 @@ export function createWorkspaceScriptsService(deps: { const project = await projectRegistry.get(workspace.projectId); emit({ type: "script_status_update", - payload: { workspaceId, scripts: buildSnapshot(workspace, project) }, + payload: { workspaceId, scripts: await buildSnapshot(workspace, project) }, }); } catch (error) { logger.warn({ err: error, workspaceId }, "Failed to project workspace script status"); @@ -163,7 +204,7 @@ export function createWorkspaceScriptsService(deps: { requireAvailable(); const workspace = await getWorkspace(workspaceId); const project = await projectRegistry.get(workspace.projectId); - return buildSnapshot(workspace, project); + return await buildSnapshot(workspace, project); } async function launchProcess(input: { workspaceId: string; scriptName: string }) { @@ -171,8 +212,21 @@ export function createWorkspaceScriptsService(deps: { const workspace = await getWorkspace(input.workspaceId); const project = await projectRegistry.get(workspace.projectId); const gitMetadata = resolveGitMetadata(workspace, project); + const paseoConfig = workspace.runtime + ? await readWorkspacePaseoConfig({ workspace, workspaceRuntime, logger }) + : undefined; + if (workspace.runtime && !paseoConfig) { + throw new Error("Workspace does not contain a valid paseo.json"); + } const result = await spawnWorkspaceScript({ repoRoot: workspace.cwd, + ...(workspace.runtime + ? { + runtimeCwd: workspace.cwd, + paseoConfig: paseoConfig!, + runtime: await workspaceRuntime!.bind(workspace.workspaceId), + } + : {}), workspaceId: workspace.workspaceId, projectSlug: gitMetadata.projectSlug, branchName: gitMetadata.currentBranch, @@ -197,13 +251,13 @@ export function createWorkspaceScriptsService(deps: { scriptName: string; }): Promise { const { workspace, project } = await launchProcess(input); - const script = buildSnapshot(workspace, project).find( + const script = (await buildSnapshot(workspace, project)).find( (entry) => entry.scriptName === input.scriptName, ); if (!script) { throw new Error(`Script '${input.scriptName}' did not produce a status record`); } - void emitStatusUpdate(workspace.workspaceId, workspace.cwd); + await emitStatusUpdate(workspace.workspaceId, workspace.cwd); return script; } @@ -225,20 +279,20 @@ export function createWorkspaceScriptsService(deps: { // The launcher's terminal exit listener owns route removal and runtime state updates. await available.terminalManager.killTerminalAndWait(runtime.terminalId); - const script = buildSnapshot(workspace, project).find( + const script = (await buildSnapshot(workspace, project)).find( (entry) => entry.scriptName === input.scriptName, ); if (!script) { throw new Error(`Script '${input.scriptName}' did not produce a status record`); } - void emitStatusUpdate(workspace.workspaceId, workspace.cwd); + await emitStatusUpdate(workspace.workspaceId, workspace.cwd); return script; } async function start(request: StartWorkspaceScriptRequest): Promise { try { const { workspace, terminalId } = await launchProcess(request); - void emitStatusUpdate(workspace.workspaceId, workspace.cwd); + await emitStatusUpdate(workspace.workspaceId, workspace.cwd); emit({ type: "start_workspace_script_response", payload: { diff --git a/packages/server/src/server/terminal-activity-route.test.ts b/packages/server/src/server/terminal-activity-route.test.ts index afc97d77e8..3cf121017e 100644 --- a/packages/server/src/server/terminal-activity-route.test.ts +++ b/packages/server/src/server/terminal-activity-route.test.ts @@ -71,7 +71,7 @@ afterEach(async () => { for (const terminal of terminalsByCwd.flat()) { await manager.killTerminalAndWait(terminal.id); } - manager.killAll(); + await manager.killAll(); manager = null; } while (temporaryDirs.length > 0) { diff --git a/packages/server/src/server/test-utils/fixtures/workspace-helper-rebind-fixture.mjs b/packages/server/src/server/test-utils/fixtures/workspace-helper-rebind-fixture.mjs new file mode 100644 index 0000000000..8154bf16aa --- /dev/null +++ b/packages/server/src/server/test-utils/fixtures/workspace-helper-rebind-fixture.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import readline from "node:readline"; + +const [helper, counterPath, ...args] = process.argv.slice(2); +if (!helper || !counterPath) throw new Error("helper and counter path are required"); + +if (args[0] !== "watch") { + await forward(); +} else { + const launch = Number(await readFile(counterPath, "utf8").catch(() => "0")) + 1; + await writeFile(counterPath, String(launch)); + if (launch !== 2) await forward(); + else await failSecondSubscription(); +} + +async function forward() { + const child = spawn(process.execPath, [helper, ...args], { stdio: "inherit" }); + const exit = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + }); + if (exit.signal) process.kill(process.pid, exit.signal); + process.exitCode = exit.code ?? 1; +} + +async function failSecondSubscription() { + process.stdout.write(`${JSON.stringify({ protocolVersion: 1, type: "ready" })}\n`); + const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + let subscriptions = 0; + for await (const line of lines) { + const message = JSON.parse(line); + if (message.type === "subscribe") { + subscriptions += 1; + if (subscriptions === 2) { + process.exitCode = 41; + return; + } + process.stdout.write( + `${JSON.stringify({ protocolVersion: 1, type: "subscribed", subscriptionId: message.id })}\n`, + ); + } else if (message.type === "unsubscribe") { + process.stdout.write( + `${JSON.stringify({ protocolVersion: 1, type: "unsubscribed", subscriptionId: message.id })}\n`, + ); + } else if (message.type === "close") return; + } +} diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 3891d90ea3..fb90856d5c 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -32,6 +32,7 @@ interface TestPaseoDaemonOptions { relayConfigCapability?: boolean; agentClients?: Partial>; providerOverrides?: PaseoDaemonConfig["providerOverrides"]; + workspaceRuntimes?: PaseoDaemonConfig["workspaceRuntimes"]; paseoHomeRoot?: string; staticDir?: string; cleanup?: boolean; @@ -178,6 +179,7 @@ async function prepareTestDaemonConfig( isDev: options.isDev, agentClients: options.agentClients ?? createTestAgentClients(), providerOverrides: options.providerOverrides, + workspaceRuntimes: options.workspaceRuntimes, agentStoragePath: path.join(paseoHome, "agents"), relayEnabled: options.relayEnabled ?? false, relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443", diff --git a/packages/server/src/server/test-utils/provider-workspace-stub.ts b/packages/server/src/server/test-utils/provider-workspace-stub.ts new file mode 100644 index 0000000000..68c5a73d08 --- /dev/null +++ b/packages/server/src/server/test-utils/provider-workspace-stub.ts @@ -0,0 +1,5 @@ +import type { AgentManagerOptions } from "../agent/agent-manager.js"; + +export const resolveHostProviderWorkspace: NonNullable< + AgentManagerOptions["resolveProviderWorkspace"] +> = async () => null; diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index 46db4e1749..a4fe410fc0 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -148,7 +148,10 @@ export function findByType( // --------------------------------------------------------------------------- export interface ProviderSnapshotManagerSpies { - getSnapshot: ReturnType>; + getSnapshot: ReturnType< + typeof vi.fn<[cwd?: string, workspaceId?: string], ProviderSnapshotEntry[]> + >; + readSnapshot: ReturnType>>; refreshSnapshotForCwd: ReturnType>>; refreshSettingsSnapshot: ReturnType>>; warmUpSnapshotForCwd: ReturnType>>; @@ -175,7 +178,13 @@ export interface ProviderSnapshotManagerSpies { export function createProviderSnapshotManagerStub(): { manager: ProviderSnapshotManager; } & ProviderSnapshotManagerSpies { - const getSnapshot = vi.fn<[cwd?: string], ProviderSnapshotEntry[]>(() => []); + const getSnapshot = vi.fn<[cwd?: string, workspaceId?: string], ProviderSnapshotEntry[]>( + () => [], + ); + const readSnapshot = vi.fn<[unknown], Promise>(async (options) => { + const { cwd, workspaceId } = options as { cwd?: string | null; workspaceId?: string }; + return getSnapshot(cwd ?? undefined, workspaceId); + }); const refreshSnapshotForCwd = vi.fn<[unknown], Promise>(async () => {}); const refreshSettingsSnapshot = vi.fn<[unknown], Promise>(async () => {}); const warmUpSnapshotForCwd = vi.fn<[unknown], Promise>(async () => {}); @@ -218,6 +227,7 @@ export function createProviderSnapshotManagerStub(): { const destroy = vi.fn<[], void>(); const stub = { getSnapshot, + readSnapshot, refreshSnapshotForCwd, refreshSettingsSnapshot, warmUpSnapshotForCwd, @@ -244,6 +254,7 @@ export function createProviderSnapshotManagerStub(): { return { manager, getSnapshot, + readSnapshot, refreshSnapshotForCwd, refreshSettingsSnapshot, warmUpSnapshotForCwd, diff --git a/packages/server/src/server/test-utils/workspace-git-service-stub.ts b/packages/server/src/server/test-utils/workspace-git-service-stub.ts index eca511c72e..3e7966241e 100644 --- a/packages/server/src/server/test-utils/workspace-git-service-stub.ts +++ b/packages/server/src/server/test-utils/workspace-git-service-stub.ts @@ -1,6 +1,10 @@ import type { CheckoutDiffResult } from "../../utils/checkout-git.js"; import { deriveProjectSlug } from "../workspace-git-metadata.js"; -import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js"; +import type { + WorkspaceGitRuntimeSnapshot, + WorkspaceGitService, + WorkspaceGitWorkspace, +} from "../workspace-git-service.js"; export function createNoGitWorkspaceRuntimeSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { return { @@ -31,7 +35,10 @@ export function createNoGitWorkspaceRuntimeSnapshot(cwd: string): WorkspaceGitRu export function createNoopWorkspaceGitService( overrides: Partial = {}, ): WorkspaceGitService { - const service: WorkspaceGitService = { + let service!: WorkspaceGitService; + service = { + bindWorkspace: ({ cwd }) => bindWorkspaceGitService(service, cwd), + bindLegacy: (cwd) => bindWorkspaceGitService(service, cwd), registerWorkspace: () => ({ unsubscribe: () => {}, }), @@ -60,6 +67,20 @@ export function createNoopWorkspaceGitService( return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null); }, resolveForge: async () => null, + commit: async () => {}, + discardChanges: async () => {}, + createBranch: async () => {}, + switchBranch: async () => ({ source: "local" }), + fetch: async () => {}, + listCommits: async () => ({ baseRef: null, commits: [] }), + getCommitFileDiff: async () => null, + stashPush: async () => {}, + stashPop: async () => {}, + mergeToBase: async (cwd) => cwd, + mergeFromBase: async () => {}, + pull: async () => {}, + push: async () => {}, + renameBranch: async (_cwd, branch) => ({ previousBranch: null, currentBranch: branch }), resolveRepoRoot: async (cwd: string) => cwd, resolveDefaultBranch: async () => "main", resolveRepoRemoteUrl: async () => null, @@ -118,3 +139,60 @@ export function createNoopWorkspaceGitService( return service; } + +export function bindWorkspaceGitService( + service: WorkspaceGitService, + cwd: string, +): WorkspaceGitWorkspace { + return { + cwd, + register: (listener) => service.registerWorkspace({ cwd }, listener), + observe: async (listener) => service.registerWorkspace({ cwd }, listener), + peekSnapshot: () => service.peekSnapshot(cwd), + getCheckout: () => service.getCheckout(cwd), + getSnapshot: (options) => + options === undefined ? service.getSnapshot(cwd) : service.getSnapshot(cwd, options), + resolveForge: () => service.resolveForge(cwd), + getCheckoutDiff: (options, readOptions) => service.getCheckoutDiff(cwd, options, readOptions), + validateBranchRef: (ref, options) => + options === undefined + ? service.validateBranchRef(cwd, ref) + : service.validateBranchRef(cwd, ref, options), + hasLocalBranch: (branch, options) => + options === undefined + ? service.hasLocalBranch(cwd, branch) + : service.hasLocalBranch(cwd, branch, options), + suggestBranches: (options, readOptions) => + readOptions === undefined + ? service.suggestBranchesForCwd(cwd, options) + : service.suggestBranchesForCwd(cwd, options, readOptions), + listStashes: (options, readOptions) => + readOptions === undefined + ? service.listStashes(cwd, options) + : service.listStashes(cwd, options, readOptions), + listWorktrees: (options) => service.listWorktrees(cwd, options), + getProjectSlug: (options) => service.getProjectSlug(cwd, options), + resolveRepoRoot: (options) => service.resolveRepoRoot(cwd, options), + resolveDefaultBranch: (options) => service.resolveDefaultBranch(cwd, options), + resolveRepoRemoteUrl: (options) => service.resolveRepoRemoteUrl(cwd, options), + commit: (options) => service.commit(cwd, options), + discardChanges: (paths) => service.discardChanges(cwd, paths), + createBranch: (options) => service.createBranch(cwd, options), + switchBranch: (branch) => service.switchBranch(cwd, branch), + fetch: () => service.fetch(cwd), + listCommits: () => service.listCommits(cwd), + getCommitFileDiff: (input) => service.getCommitFileDiff(cwd, input), + stashPush: (message) => service.stashPush(cwd, message), + stashPop: (stashIndex) => service.stashPop(cwd, stashIndex), + mergeToBase: (options) => service.mergeToBase(cwd, options), + mergeFromBase: (options) => service.mergeFromBase(cwd, options), + pull: () => service.pull(cwd), + push: () => service.push(cwd), + renameBranch: (branch) => service.renameBranch(cwd, branch), + refresh: (options) => service.refresh(cwd, options), + requestWorkingTreeWatch: (onChange) => service.requestWorkingTreeWatch(cwd, onChange), + scheduleRefresh: () => service.scheduleRefreshForCwd(cwd), + stateMayHaveChanged: () => service.onWorkspaceStateMayHaveChanged(cwd), + invalidateForge: () => service.invalidateForge(cwd), + }; +} diff --git a/packages/server/src/server/utils/diff-highlighter.ts b/packages/server/src/server/utils/diff-highlighter.ts index c016318e47..bbbb8c4e32 100644 --- a/packages/server/src/server/utils/diff-highlighter.ts +++ b/packages/server/src/server/utils/diff-highlighter.ts @@ -29,11 +29,13 @@ export interface ParsedDiffFile { interface HighlightDiffWithFileContentOptions { oldFileContent?: string | null; newFileContent?: string | null; + allowFileRead?: boolean; } interface ParseAndHighlightDiffOptions { getOldFileContent?: (file: ParsedDiffFile) => Promise; getNewFileContent?: (file: ParsedDiffFile) => Promise; + allowFileRead?: boolean; } /** @@ -319,6 +321,10 @@ export async function highlightDiffWithFileContent( return applyTokensToHunks(file, newTokensByLine, oldTokensByLine); } + if (options.allowFileRead === false) { + return applyTokensToHunks(file, newTokensByLine, oldTokensByLine); + } + const filePath = resolve(cwd, file.path); try { const fileContent = await readFile(filePath, "utf-8"); @@ -388,6 +394,7 @@ export async function parseAndHighlightDiff( return highlightDiffWithFileContent(file, cwd, { oldFileContent: oldFileContent ?? undefined, newFileContent: newFileContent ?? undefined, + allowFileRead: options.allowFileRead, }); }), ); diff --git a/packages/server/src/server/websocket-server.browser-tools.test.ts b/packages/server/src/server/websocket-server.browser-tools.test.ts index ba83e979d1..1a897eecf3 100644 --- a/packages/server/src/server/websocket-server.browser-tools.test.ts +++ b/packages/server/src/server/websocket-server.browser-tools.test.ts @@ -23,6 +23,7 @@ import { DaemonClient } from "./test-utils/daemon-client.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import { VoiceAssistantWebSocketServer } from "./websocket-server.js"; import type { WorkspaceAutoName } from "./workspace-auto-name.js"; +import type { WorkspaceRuntimeService } from "./workspace-runtime/index.js"; interface BrowserToolsDaemonHarness { broker: BrowserToolsBroker; @@ -331,6 +332,9 @@ function createVoiceAssistantWebSocketServer(params: { undefined, undefined, broker, + undefined, + undefined, + createStub({}), ); } diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 629b718bcb..e4635c34b1 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -17,6 +17,7 @@ import { TerminalStreamOpcode, } from "@getpaseo/protocol/terminal-stream-protocol"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; +import type { WorkspaceRuntimeService } from "./workspace-runtime/index.js"; type SocketListener = (...args: unknown[]) => void; @@ -294,6 +295,12 @@ function createServer(options?: { undefined, undefined, createProviderSnapshotManagerStub().manager, + undefined, + undefined, + undefined, + undefined, + undefined, + createStub({}), ); } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index fb0d705f41..ef3243b2a2 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -50,6 +50,8 @@ import type { import type { GitCommandRuntimeMetricsSnapshot } from "../utils/git-command-runtime-metrics.js"; import { snapshotGitCommandRuntimeMetrics } from "../utils/run-git-command.js"; import type { WorkspaceAutoName } from "./workspace-auto-name.js"; +import type { WorkspaceRuntimeService } from "./workspace-runtime/index.js"; +import type { ProviderProbeService } from "./provider-probe/index.js"; import { deriveProjectSlug } from "./workspace-git-metadata.js"; import { createPushNotifications, @@ -206,6 +208,12 @@ function createFallbackWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSna function createFallbackWorkspaceGitService(): WorkspaceGitService { return { + bindWorkspace: ({ workspaceId }) => { + throw new Error(`Workspace Git runtime is not available: ${workspaceId}`); + }, + bindLegacy: () => { + throw new Error("Workspace Git service is not available"); + }, registerWorkspace: () => ({ unsubscribe: () => {}, }), @@ -224,6 +232,20 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService { }), getSnapshot: async (cwd: string) => createFallbackWorkspaceGitSnapshot(cwd), resolveForge: async () => null, + commit: async () => {}, + discardChanges: async () => {}, + createBranch: async () => {}, + switchBranch: async () => ({ source: "local" }), + fetch: async () => {}, + listCommits: async () => ({ baseRef: null, commits: [] }), + getCommitFileDiff: async () => null, + stashPush: async () => {}, + stashPop: async () => {}, + mergeToBase: async (cwd) => cwd, + mergeFromBase: async () => {}, + pull: async () => {}, + push: async () => {}, + renameBranch: async (_cwd, branch) => ({ previousBranch: null, currentBranch: branch }), getCheckoutDiff: async () => ({ diff: "" }), validateBranchRef: async () => ({ kind: "not-found" }), hasLocalBranch: async () => false, @@ -325,6 +347,7 @@ function createNoopWorkspaceRegistry(): WorkspaceRegistry { upsert: async () => {}, archive: async () => {}, remove: async () => {}, + requestDeletion: async () => {}, }; } @@ -537,6 +560,8 @@ export class VoiceAssistantWebSocketServer { private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; + private readonly workspaceRuntime: WorkspaceRuntimeService | undefined; + private readonly providerProbe: ProviderProbeService | undefined; private readonly scheduleService: ScheduleService; private readonly checkoutDiffManager: CheckoutDiffManager; private readonly github: ForgeService; @@ -632,6 +657,8 @@ export class VoiceAssistantWebSocketServer { browserToolsBroker?: BrowserToolsBroker | null, hubRelationships?: HubRelationshipManagement | null, workspaceSetupRuntime: WorkspaceSetupRuntime = new WorkspaceSetupRuntime(), + workspaceRuntime?: WorkspaceRuntimeService, + providerProbe?: ProviderProbeService, pluginRuntime?: SessionOptions["pluginRuntime"], ) { this.logger = logger.child({ module: "websocket-server" }); @@ -651,6 +678,8 @@ export class VoiceAssistantWebSocketServer { this.agentStorage = agentStorage; this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry(); + this.workspaceRuntime = workspaceRuntime; + this.providerProbe = providerProbe; const requiredServices = requireWebSocketServices({ scheduleService, checkoutDiffManager, @@ -1342,6 +1371,9 @@ export class VoiceAssistantWebSocketServer { } private createSocketSession(options: SocketSessionOptions): Session { + if (!this.workspaceRuntime) { + throw new Error("Workspace runtime service was not composed"); + } return new Session({ clientId: options.clientId, appVersion: options.appVersion, @@ -1369,6 +1401,8 @@ export class VoiceAssistantWebSocketServer { agentStorage: this.agentStorage, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, + workspaceRuntime: this.workspaceRuntime, + providerProbe: this.providerProbe, directorySync: this.directorySync, scheduleService: this.scheduleService, checkoutDiffManager: this.checkoutDiffManager, @@ -1599,6 +1633,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-archive-record-scoped.e2e.test.ts b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts index 7e2b0c2064..db6032e194 100644 --- a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts +++ b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts @@ -1,5 +1,5 @@ import { execFileSync, spawn } from "node:child_process"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, expect, test } from "vitest"; @@ -248,7 +248,7 @@ test("renaming a workspace updates every subscribed client", async () => { } }); -test("archiving the last reference to a worktree removes it from disk regardless of the disk flag", async () => { +test("archive preserves an owned worktree and permanent project deletion removes it", async () => { const repoDir = createGitRepo(); const keepResult = await ctx.client.createWorkspace({ @@ -261,43 +261,18 @@ test("archiving the last reference to a worktree removes it from disk regardless const keepDir = keepWorkspace.workspaceDirectory; expect(existsSync(keepDir)).toBe(true); - // Last reference, deleteWorktreeFromDisk omitted (defaults ignored) → dir removed. const keepArchive = await ctx.client.archivePaseoWorktree({ worktreePath: keepDir }); expect(keepArchive.success).toBe(true); - await expect - .poll(async () => (await activeWorkspaceIds()).has(keepWorkspace.id), { - timeout: 10000, - interval: 100, - }) - .toBe(false); - await expect.poll(() => existsSync(keepDir), { timeout: 10000, interval: 100 }).toBe(false); + expect((await activeWorkspaceIds()).has(keepWorkspace.id)).toBe(false); + expect(existsSync(keepDir)).toBe(true); - const deleteResult = await ctx.client.createWorkspace({ - source: { - kind: "worktree", - cwd: repoDir, - worktreeSlug: "delete-from-disk", - baseBranch: "main", - }, - }); - const deleteWorkspace = deleteResult.workspace; - if (!deleteWorkspace?.workspaceDirectory) { - throw new Error(deleteResult.error ?? "Failed to create worktree workspace"); - } - const deleteDir = deleteWorkspace.workspaceDirectory; - expect(existsSync(deleteDir)).toBe(true); + await ctx.client.restoreWorkspace(keepWorkspace.id); + expect((await activeWorkspaceIds()).has(keepWorkspace.id)).toBe(true); + expect(existsSync(keepDir)).toBe(true); - // Last reference on a fresh worktree still removes the directory without any - // caller-supplied disk-deletion flag. - const deleteArchive = await ctx.client.archivePaseoWorktree({ worktreePath: deleteDir }); - expect(deleteArchive.success).toBe(true); - await expect - .poll(async () => (await activeWorkspaceIds()).has(deleteWorkspace.id), { - timeout: 10000, - interval: 100, - }) - .toBe(false); - await expect.poll(() => existsSync(deleteDir), { timeout: 10000, interval: 100 }).toBe(false); + const removed = await ctx.client.removeProject(keepWorkspace.projectId); + expect(removed.removedWorkspaceIds).toContain(keepWorkspace.id); + expect(existsSync(keepDir)).toBe(false); }, 60000); test.skipIf(process.platform === "win32")( @@ -305,18 +280,36 @@ test.skipIf(process.platform === "win32")( async () => { const repoDir = createGitRepo(); const setupStartedPath = path.join(repoDir, "setup-started"); - const stopSetupPath = path.join(repoDir, "stop-setup"); + const descendantPidPath = path.join(repoDir, "setup-descendant.pid"); + writeFileSync( + path.join(repoDir, "setup-descendant.mjs"), + `process.on("SIGTERM", () => {}); setInterval(() => {}, 1000);\n`, + ); + writeFileSync( + path.join(repoDir, "setup-owner.mjs"), + `import { spawn } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import path from "node:path"; +const source = process.env.PASEO_SOURCE_CHECKOUT_PATH; +const child = spawn(process.execPath, ["setup-descendant.mjs"], { stdio: "ignore" }); +writeFileSync(path.join(source, "setup-descendant.pid"), String(child.pid)); +writeFileSync(path.join(source, "setup-started"), "started"); +process.on("SIGTERM", () => {}); +setInterval(() => {}, 1000); +`, + ); writeFileSync( path.join(repoDir, "paseo.json"), JSON.stringify({ worktree: { - setup: [ - `node -e "const fs=require('fs'),path=require('path');const source=process.env.PASEO_SOURCE_CHECKOUT_PATH;const worktree=process.env.PASEO_WORKTREE_PATH;const target=path.join(worktree,'node_modules/react-native-svg/lib/typescript');fs.writeFileSync(path.join(source,'setup-started'),'started');while(!fs.existsSync(path.join(source,'stop-setup'))){try{fs.mkdirSync(target,{recursive:true});fs.writeFileSync(path.join(target,'active'),String(Date.now()))}catch{}}"`, - ], + setup: [`${JSON.stringify(process.execPath)} setup-owner.mjs`], }, }), ); - execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["add", "paseo.json", "setup-owner.mjs", "setup-descendant.mjs"], { + cwd: repoDir, + stdio: "pipe", + }); execFileSync( "git", ["-c", "commit.gpgsign=false", "commit", "-m", "add active worktree setup"], @@ -338,8 +331,13 @@ test.skipIf(process.platform === "win32")( try { await expect.poll(() => existsSync(setupStartedPath), { timeout: 10000 }).toBe(true); + const descendantPid = Number.parseInt(readFileSync(descendantPidPath, "utf8"), 10); + expect(() => process.kill(descendantPid, 0)).not.toThrow(); const archive = await ctx.client.archiveWorkspace(workspace.id); + expect(() => process.kill(descendantPid, 0)).toThrow( + expect.objectContaining({ code: "ESRCH" }), + ); const retry = await ctx.client.archiveWorkspace(workspace.id); expect({ archiveError: archive.error, retryError: retry.error }).toEqual({ @@ -347,16 +345,23 @@ test.skipIf(process.platform === "win32")( retryError: null, }); expect((await activeWorkspaceIds()).has(workspace.id)).toBe(false); - expect(existsSync(workspace.workspaceDirectory)).toBe(false); + expect(existsSync(workspace.workspaceDirectory)).toBe(true); } finally { - writeFileSync(stopSetupPath, "stop\n"); + if (existsSync(descendantPidPath)) { + const descendantPid = Number.parseInt(readFileSync(descendantPidPath, "utf8"), 10); + try { + process.kill(descendantPid, "SIGKILL"); + } catch { + // The production archive path reaped the setup process tree. + } + } } }, 60000, ); test.skipIf(process.platform === "win32")( - "repeating archive removes a residual worktree for an already-archived workspace", + "repeating archive preserves the same runtime-owned worktree", async () => { const repoDir = createGitRepo(); const result = await ctx.client.createWorkspace({ @@ -403,7 +408,7 @@ test.skipIf(process.platform === "win32")( const retry = await ctx.client.archiveWorkspace(workspace.id); expect(retry.error).toBeNull(); - expect(existsSync(workspace.workspaceDirectory)).toBe(false); + expect(existsSync(workspace.workspaceDirectory)).toBe(true); } finally { writeFileSync(stopWriterPath, "stop\n"); await writerExit; @@ -484,16 +489,10 @@ test("keeps the worktree on disk when a sibling workspace still references it", // directory must survive regardless of the legacy disk flag. const archive = await ctx.client.archivePaseoWorktree({ worktreePath: worktreeDir, + workspaceId: worktreeWorkspace.id, }); expect(archive.success).toBe(true); - await expect - .poll(async () => (await activeWorkspaceIds()).has(worktreeWorkspace.id), { - timeout: 10000, - interval: 100, - }) - .toBe(false); - const remaining = await activeWorkspaceIds(); expect(remaining.has(worktreeWorkspace.id)).toBe(false); expect(remaining.has(siblingWorkspaceId)).toBe(true); diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index ac70ecbefb..604216b639 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -1,5 +1,13 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import pino, { type Logger } from "pino"; @@ -11,6 +19,7 @@ import { createWorktree, type WorktreeConfig } from "../utils/worktree.js"; import type { ManagedAgent } from "./agent/agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; +import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; import { archiveByScope, type ActiveWorkspaceRef, @@ -188,6 +197,176 @@ function assertArchiveResult( } describe("archiveByScope", () => { + test("reports a target teardown failure after settling the archive operation", async () => { + const { tempDir, repoDir } = createGitRepo(); + const workspaceId = "ws-failed-runtime-archive"; + const deps = createArchiveDeps({ + paseoHome: path.join(tempDir, ".paseo"), + activeWorkspaces: [ + { + workspaceId, + cwd: repoDir, + kind: "local_checkout", + runtimeId: "local", + hostVisiblePath: repoDir, + }, + ], + }); + deps.archiveWorkspaceRecord = vi.fn(async () => { + throw new Error("runtime archive failed"); + }); + + await expect( + archiveByScope(deps, { + scope: { kind: "workspace", workspaceId }, + requestId: "req-failed-runtime-archive", + }), + ).rejects.toThrow("Failed to archive 1 workspace"); + + expect(deps.clearWorkspaceArchiving).toHaveBeenCalledWith([workspaceId]); + }); + + test("an external runtime never exposes its host source checkout as removable backing", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const sourceWorktree = await createPaseoOwnedWorktree( + repoDir, + paseoHome, + "external-source-decoy", + ); + const workspaceId = "ws-external-source-decoy"; + const deps = createArchiveDeps({ + paseoHome, + activeWorkspaces: [ + { + workspaceId, + cwd: "/runtime/workspace", + hostVisiblePath: sourceWorktree.worktreePath, + kind: "worktree", + runtimeId: "external-sandbox", + worktreeRoot: sourceWorktree.worktreePath, + isPaseoOwnedWorktree: true, + mainRepoRoot: repoDir, + }, + ], + }); + + const result = await archiveByScope(deps, { + scope: { kind: "workspace", workspaceId }, + requestId: "req-external-source-decoy", + releaseBacking: true, + }); + + expect(result.removedDirectory).toBe(false); + expect(existsSync(sourceWorktree.worktreePath)).toBe(true); + }); + + test("host-invisible runtimes sharing a public cwd release independently", async () => { + const { tempDir } = createGitRepo(); + const workspaceId = "ws-host-invisible-a"; + const deps = createArchiveDeps({ + paseoHome: path.join(tempDir, ".paseo"), + activeWorkspaces: [ + { workspaceId, cwd: "/workspace", kind: "git", runtimeId: "remote-a" }, + { + workspaceId: "ws-host-invisible-b", + cwd: "/workspace", + kind: "git", + runtimeId: "remote-b", + }, + ], + }); + const archiveWorkspaceRecord = deps.archiveWorkspaceRecord; + const releases: boolean[] = []; + deps.archiveWorkspaceRecord = async (targetWorkspaceId, options) => { + releases.push(options?.releaseBacking === true); + await archiveWorkspaceRecord(targetWorkspaceId, options); + }; + + await archiveByScope(deps, { + scope: { kind: "workspace", workspaceId }, + requestId: "req-host-invisible-release", + releaseBacking: true, + }); + + expect(releases).toEqual([true]); + expect(deps.activeWorkspaces.map((workspace) => workspace.workspaceId)).toContain( + "ws-host-invisible-b", + ); + }); + + test("releases an archived runtime owner when the final sibling reference is archived", async () => { + const { tempDir, repoDir } = createGitRepo(); + const ownerWorkspaceId = "ws-deferred-owner"; + const siblingWorkspaceId = "ws-final-sibling"; + const deps = createArchiveDeps({ + paseoHome: path.join(tempDir, ".paseo"), + activeWorkspaces: [ + { + workspaceId: siblingWorkspaceId, + cwd: repoDir, + hostVisiblePath: repoDir, + kind: "local_checkout", + runtimeId: "local", + }, + ], + }); + let siblingArchived = false; + const archiveWorkspaceRecord = deps.archiveWorkspaceRecord; + const archiveCalls: Array<{ workspaceId: string; releaseBacking: boolean }> = []; + deps.archiveWorkspaceRecord = async (workspaceId, options) => { + archiveCalls.push({ workspaceId, releaseBacking: options?.releaseBacking === true }); + if (workspaceId === siblingWorkspaceId) { + siblingArchived = true; + await archiveWorkspaceRecord(workspaceId, options); + } + }; + const workspaceRecord = ( + workspaceId: string, + runtimeId: string, + archivedAt: string | null, + ): PersistedWorkspaceRecord => ({ + workspaceId, + projectId: "project-deferred-owner", + cwd: repoDir, + hostVisiblePath: repoDir, + kind: "worktree", + displayName: workspaceId, + title: null, + branch: null, + worktreeRoot: repoDir, + baseBranch: null, + isPaseoOwnedWorktree: runtimeId === "worktree", + mainRepoRoot: repoDir, + runtime: { runtimeId }, + deletionRequestedAt: null, + createdAt: "2026-08-14T00:00:00.000Z", + updatedAt: "2026-08-14T00:00:00.000Z", + archivedAt, + autoArchivedChangeRequestUrl: null, + pinnedAt: null, + }); + deps.listWorkspaceRecords = async () => [ + workspaceRecord(ownerWorkspaceId, "worktree", "2026-08-14T00:00:00.000Z"), + workspaceRecord( + siblingWorkspaceId, + "local", + siblingArchived ? "2026-08-14T00:01:00.000Z" : null, + ), + ]; + + await archiveByScope(deps, { + scope: { kind: "workspace", workspaceId: siblingWorkspaceId }, + requestId: "req-release-deferred-owner", + releaseBacking: true, + }); + + expect(archiveCalls).toEqual([ + { workspaceId: siblingWorkspaceId, releaseBacking: true }, + { workspaceId: ownerWorkspaceId, releaseBacking: true }, + ]); + }); + test("workspace scope archives the record and removes the directory on last reference", async () => { const { tempDir, repoDir } = createGitRepo(); const paseoHome = path.join(tempDir, ".paseo"); @@ -528,14 +707,14 @@ describe("archiveByScope", () => { return originalArchiveWorkspaceRecord(workspaceId); }; - const result = await archiveByScope(deps, { - scope: { kind: "worktree", targetPath: worktree.worktreePath }, - requestId: "req-partial-failure", - }); + await expect( + archiveByScope(deps, { + scope: { kind: "worktree", targetPath: worktree.worktreePath }, + requestId: "req-partial-failure", + }), + ).rejects.toThrow("Failed to archive 1 workspace"); - expect(result.archivedWorkspaceIds).toEqual([workspaceB]); - expect(result.archivedWorkspaceIds).not.toContain(workspaceA); - expect(result.removedDirectory).toBe(false); + expect(deps.activeWorkspaces.map((workspace) => workspace.workspaceId)).toEqual([workspaceA]); expect(existsSync(worktree.worktreePath)).toBe(true); }); @@ -686,7 +865,15 @@ describe("archiveByScope", () => { const deps = createArchiveDeps({ paseoHome, activeWorkspaces: [ - { workspaceId: targetWorkspaceId, cwd: worktree.worktreePath, kind: "worktree" }, + { + workspaceId: targetWorkspaceId, + cwd: worktree.worktreePath, + kind: "worktree", + runtimeId: "worktree", + worktreeRoot: worktree.worktreePath, + isPaseoOwnedWorktree: true, + mainRepoRoot: repoDir, + }, ], }); deps.agentManager = { @@ -716,17 +903,18 @@ describe("archiveByScope", () => { const result = await archiveByScope(deps, { scope: { kind: "workspace", workspaceId: targetWorkspaceId }, requestId: "req-snapshot-scope", + releaseBacking: true, }); assertArchiveResult(result, { archivedWorkspaceIds: [targetWorkspaceId], - removedDirectory: true, + removedDirectory: false, }); expect(result.archivedAgentIds).toContain(liveAgentId); expect(result.archivedAgentIds).toContain(targetStoredAgentId); expect(result.archivedAgentIds).not.toContain(otherStoredAgentId); expect(deps.archivedSnapshotIds).toEqual([targetStoredAgentId]); - expect(existsSync(worktree.worktreePath)).toBe(false); + expect(existsSync(worktree.worktreePath)).toBe(true); }); test("archives the durable snapshot when an observed live agent closes before teardown", async () => { @@ -794,21 +982,38 @@ describe("archiveByScope", () => { }); describe("resolveWorkspaceIdAtPath", () => { - test("prefers the worktree-kind record on an exact cwd tie", async () => { - const targetPath = "/worktrees/repo/feature"; - - const result = await resolveWorkspaceIdAtPath( - { - listActiveWorkspaces: async () => [ - { workspaceId: "ws-local", cwd: targetPath, kind: "local_checkout" }, - { workspaceId: "ws-worktree", cwd: targetPath, kind: "worktree" }, - ], - findWorkspaceIdForCwd: vi.fn(async () => "ws-local"), - }, - targetPath, - ); - - expect(result).toBe("ws-worktree"); + test("fails closed when duplicate records alias the same host-visible path", async () => { + const root = mkdtempSync(path.join(tmpdir(), "workspace-archive-alias-")); + cleanupPaths.push(root); + const targetPath = path.join(root, "target"); + const aliasPath = path.join(root, "alias"); + mkdirSync(targetPath); + symlinkSync(targetPath, aliasPath, process.platform === "win32" ? "junction" : "dir"); + + await expect( + resolveWorkspaceIdAtPath( + { + listActiveWorkspaces: async () => [ + { + workspaceId: "ws-first", + cwd: "/presentation/one", + hostVisiblePath: targetPath, + runtimeId: "local", + kind: "local_checkout", + }, + { + workspaceId: "ws-second", + cwd: "/presentation/two", + hostVisiblePath: aliasPath, + runtimeId: "worktree", + kind: "worktree", + }, + ], + findWorkspaceIdForCwd: async () => "ws-first", + }, + targetPath, + ), + ).rejects.toThrow("Ambiguous workspace path"); }); test("falls back to the path resolver when there is no exact match", async () => { diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index 8b37cae3f1..a65f2bb3f0 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -24,7 +24,7 @@ import { runWithGitCommandPriority } from "../utils/run-git-command.js"; export type ActiveWorkspaceRef = Pick< PersistedWorkspaceRecord, "workspaceId" | "cwd" | "kind" | "worktreeRoot" | "isPaseoOwnedWorktree" | "mainRepoRoot" ->; +> & { hostVisiblePath?: string | null; runtimeId?: string | null }; export interface ArchiveDependencies { paseoHome?: string; @@ -41,10 +41,16 @@ export interface ArchiveDependencies { getWorkspace?: (workspaceId: string) => Promise; // Active (non-archived) workspaces, used to decide whether the workspace being // archived is the last reference to its backing worktree directory, and to - // break a same-cwd tie in favor of the worktree-kind record when archiving by - // path (no explicit workspaceId). + // reject ambiguous same-cwd matches when archiving by path without an explicit + // workspaceId. listActiveWorkspaces: () => Promise; - archiveWorkspaceRecord: (workspaceId: string) => Promise; + // Complete durable workspace set, including archived runtime owners whose + // backing may have been retained while another workspace referenced it. + listWorkspaceRecords?: () => Promise; + archiveWorkspaceRecord: ( + workspaceId: string, + options?: { releaseBacking?: boolean }, + ) => Promise; emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; clearWorkspaceArchiving: (workspaceIds: Iterable) => void; @@ -72,6 +78,7 @@ export interface ArchiveResult { export interface ArchiveByScopeRequest { scope: ArchiveScope; requestId: string; + releaseBacking?: boolean; } export async function requireActiveWorkspaceForArchive( @@ -96,6 +103,8 @@ interface BackingDirectory { interface ArchiveTarget { backing: BackingDirectory | null; + runtimeManaged: boolean; + runtimeReferencePath: string | null; teardownTargets: Array<{ workspaceId: string | null; cwd: string }>; setupWorkspaceIds: string[]; workspaceIds: string[]; @@ -107,11 +116,19 @@ export async function resolveWorkspaceIdAtPath( ): Promise { const matchesTarget = createRealpathAwarePathMatcher(targetPath); const activeWorkspaces = await dependencies.listActiveWorkspaces(); - const exactMatches = activeWorkspaces.filter((workspace) => matchesTarget(workspace.cwd)); - const worktreeMatch = exactMatches.find((workspace) => workspace.kind === "worktree"); - if (worktreeMatch) { - return worktreeMatch.workspaceId; + const exactMatches = activeWorkspaces.filter((workspace) => { + const visiblePath = workspace.runtimeId ? workspace.hostVisiblePath : workspace.cwd; + return visiblePath ? matchesTarget(visiblePath) : false; + }); + if (exactMatches.length > 1) { + throw new Error( + `Ambiguous workspace path '${targetPath}' matches: ${exactMatches + .map((workspace) => workspace.workspaceId) + .sort() + .join(", ")}`, + ); } + if (exactMatches.length === 1) return exactMatches[0]!.workspaceId; return dependencies.findWorkspaceIdForCwd(targetPath); } @@ -145,12 +162,38 @@ async function archiveByScopeWithPriority( await dependencies.emitWorkspaceUpdatesForWorkspaceIds(targetWorkspaceIds); } - const { archivedAgents, archivedWorkspaceIds } = await archiveTargetRecords( + const releaseBacking = request.releaseBacking ?? !target.runtimeManaged; + const releaseRuntimeBacking = + releaseBacking && + (target.runtimeReferencePath === null || + (await isDirectoryUnreferenced( + await dependencies.listActiveWorkspaces(), + target.runtimeReferencePath, + new Set(targetWorkspaceIds), + dependencies, + ))); + const { archivedAgents, archivedWorkspaceIds, failures } = await archiveTargetRecords( dependencies, targetWorkspaceIds, request.requestId, + releaseRuntimeBacking, ); + if ( + releaseRuntimeBacking && + target.runtimeReferencePath !== null && + archivedWorkspaceIds.length > 0 + ) { + failures.push( + ...(await releaseDeferredRuntimeBackings( + dependencies, + target.runtimeReferencePath, + new Set(archivedWorkspaceIds), + request.requestId, + )), + ); + } + if (target.backing?.mainRepoRoot) { try { await dependencies.workspaceGitService.getSnapshot(target.backing.mainRepoRoot, { @@ -165,7 +208,7 @@ async function archiveByScopeWithPriority( } } - if (target.backing !== null) { + if (releaseBacking && target.backing !== null) { removedDirectory = await maybeRemoveDirectory( dependencies, request, @@ -174,6 +217,13 @@ async function archiveByScopeWithPriority( ); } + if (failures.length > 0) { + throw new AggregateError( + failures, + `Failed to archive ${failures.length} workspace${failures.length === 1 ? "" : "s"}`, + ); + } + return { archivedAgentIds: Array.from(archivedAgents), archivedWorkspaceIds, @@ -187,6 +237,42 @@ async function archiveByScopeWithPriority( } } +async function releaseDeferredRuntimeBackings( + dependencies: ArchiveDependencies, + referencePath: string, + justArchivedWorkspaceIds: ReadonlySet, + requestId: string, +): Promise { + const matchesReferencePath = createRealpathAwarePathMatcher(referencePath); + const deferredOwners = ((await dependencies.listWorkspaceRecords?.()) ?? []).filter( + (workspace) => + workspace.archivedAt !== null && + workspace.runtime !== undefined && + !justArchivedWorkspaceIds.has(workspace.workspaceId) && + workspace.hostVisiblePath !== null && + matchesReferencePath(workspace.hostVisiblePath), + ); + const results = await Promise.allSettled( + deferredOwners.map((workspace) => + dependencies.archiveWorkspaceRecord(workspace.workspaceId, { releaseBacking: true }), + ), + ); + const failures: unknown[] = []; + for (const [index, result] of results.entries()) { + if (result?.status !== "rejected") continue; + failures.push(result.reason); + dependencies.sessionLogger?.warn( + { + err: result.reason, + requestId, + workspaceId: deferredOwners[index]?.workspaceId, + }, + "Deferred workspace runtime backing release failed", + ); + } + return failures; +} + async function resolveArchiveTarget( dependencies: ArchiveDependencies, scope: ArchiveScope, @@ -203,11 +289,20 @@ async function resolveArchiveTarget( { workspaceId }, "Workspace not found for archive-by-scope; skipping", ); - return { backing: null, teardownTargets: [], setupWorkspaceIds: [], workspaceIds: [] }; + return { + backing: null, + runtimeManaged: false, + runtimeReferencePath: null, + teardownTargets: [], + setupWorkspaceIds: [], + workspaceIds: [], + }; } const isArchived = "archivedAt" in record && Boolean(record.archivedAt); return { backing: await resolveWorkspaceBackingDirectory(record, dependencies), + runtimeManaged: hasSelectedRuntime(record), + runtimeReferencePath: hasSelectedRuntime(record) ? (record.hostVisiblePath ?? null) : null, teardownTargets: isArchived ? [] : [{ workspaceId, cwd: record.cwd }], setupWorkspaceIds: [workspaceId], workspaceIds: isArchived ? [] : [workspaceId], @@ -232,6 +327,11 @@ async function resolveArchiveTarget( ...backing, mainRepoRoot: persistedMainRepoRoot ?? backing.mainRepoRoot, }, + runtimeManaged: targetWorkspaces.some(hasSelectedRuntime), + runtimeReferencePath: + targetWorkspaces.find( + (workspace) => hasSelectedRuntime(workspace) && workspace.hostVisiblePath, + )?.hostVisiblePath ?? null, teardownTargets: targetWorkspaces.length > 0 ? targetWorkspaces.map((workspace) => ({ @@ -269,6 +369,14 @@ async function resolveWorkspaceBackingDirectory( workspace: ActiveWorkspaceRef, dependencies: Pick, ): Promise { + if (hasSelectedRuntime(workspace)) { + return { + path: resolve(workspace.hostVisiblePath ?? workspace.cwd), + isPaseoOwnedWorktree: false, + mainRepoRoot: workspace.mainRepoRoot ?? null, + paseoWorktreesRoot: null, + }; + } if (workspace.isPaseoOwnedWorktree && workspace.worktreeRoot && workspace.mainRepoRoot) { return { path: resolve(workspace.worktreeRoot), @@ -316,14 +424,20 @@ async function archiveTargetRecords( dependencies: ArchiveDependencies, targetWorkspaceIds: string[], requestId: string, -): Promise<{ archivedAgents: Set; archivedWorkspaceIds: string[] }> { + releaseBacking: boolean, +): Promise<{ + archivedAgents: Set; + archivedWorkspaceIds: string[]; + failures: unknown[]; +}> { const archivedAgents = new Set(); const archivedWorkspaceIds: string[] = []; + const failures: unknown[] = []; const results = await Promise.allSettled( targetWorkspaceIds.map(async (workspaceId) => { const agents = await archiveWorkspaceContents(dependencies, workspaceId); - await dependencies.archiveWorkspaceRecord(workspaceId); + await dependencies.archiveWorkspaceRecord(workspaceId, { releaseBacking }); return { workspaceId, agents }; }), ); @@ -335,14 +449,15 @@ async function archiveTargetRecords( archivedAgents.add(agentId); } } else { + failures.push(result.reason); dependencies.sessionLogger?.warn( { err: result.reason, requestId }, - "archiveByScope workspace teardown failed; continuing", + "archiveByScope workspace teardown failed", ); } } - return { archivedAgents, archivedWorkspaceIds }; + return { archivedAgents, archivedWorkspaceIds, failures }; } async function maybeRemoveDirectory( @@ -418,6 +533,13 @@ async function maybeRemoveDirectory( } } +function hasSelectedRuntime(workspace: ActiveWorkspaceRef): boolean { + return ( + workspace.runtimeId != null || + ("runtime" in workspace && Boolean((workspace as { runtime?: unknown }).runtime)) + ); +} + function uniqueFilesystemPaths(paths: string[]): string[] { const unique: string[] = []; for (const candidate of paths) { diff --git a/packages/server/src/server/workspace-git-directory.test.ts b/packages/server/src/server/workspace-git-directory.test.ts new file mode 100644 index 0000000000..3a7a7c4e0a --- /dev/null +++ b/packages/server/src/server/workspace-git-directory.test.ts @@ -0,0 +1,100 @@ +import { resolve } from "node:path"; +import { expect, test, vi } from "vitest"; +import { createWorkspaceGitDirectory } from "./workspace-git-directory.js"; +import type { WorkspaceGitWorkspace } from "./workspace-git-service.js"; + +test("selected Git addresses cannot become legacy through empty or omitted identity", async () => { + const cwd = "/shared"; + const selected = { cwd } as WorkspaceGitWorkspace; + const legacy = { cwd } as WorkspaceGitWorkspace; + const record = { + workspaceId: "workspace-selected", + cwd, + hostVisiblePath: null, + runtime: { runtimeId: "command" }, + }; + const get = vi.fn(async (workspaceId: string) => + workspaceId === record.workspaceId ? record : null, + ); + const directory = createWorkspaceGitDirectory({ + workspaceRegistry: { get, list: async () => [record] }, + workspaceGitService: { + bindWorkspace: () => selected, + bindLegacy: () => legacy, + }, + }); + + for (const workspaceId of ["", " "]) { + await expect(directory.resolve({ kind: "selected", workspaceId, cwd })).rejects.toThrow( + "workspaceId is required for selected workspace Git", + ); + } + await expect(directory.resolve({ kind: "legacy", cwd })).rejects.toThrow( + "workspaceId is required for a selected workspace Git operation", + ); + expect(get).not.toHaveBeenCalled(); +}); + +test.each(["", " "])( + "selected Git addresses reject cwd %j before registry lookup", + async (cwd) => { + const get = vi.fn(); + const directory = createWorkspaceGitDirectory({ + workspaceRegistry: { get, list: vi.fn() }, + workspaceGitService: { + bindWorkspace: vi.fn(), + bindLegacy: vi.fn(), + }, + }); + + await expect( + directory.resolve({ kind: "selected", workspaceId: "workspace-a", cwd }), + ).rejects.toThrow("cwd is required for selected workspace Git"); + expect(get).not.toHaveBeenCalled(); + }, +); + +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(resolve(cwd)); +}); + +test("selected Git addresses normalize both fields before registry lookup", async () => { + const runtimeCwd = process.platform === "win32" ? "/workspace" : String.raw`C:\workspace`; + const record = { + workspaceId: "workspace-a", + cwd: runtimeCwd, + runtime: { runtimeId: "command" }, + }; + const get = vi.fn(async () => record); + const selected = { cwd: record.cwd } as WorkspaceGitWorkspace; + const bindWorkspace = vi.fn(() => selected); + const directory = createWorkspaceGitDirectory({ + workspaceRegistry: { get, list: vi.fn() }, + workspaceGitService: { bindWorkspace, bindLegacy: vi.fn() }, + }); + + await expect( + directory.resolve({ + kind: "selected", + workspaceId: " workspace-a ", + cwd: ` ${runtimeCwd} `, + }), + ).resolves.toBe(selected); + expect(get).toHaveBeenCalledWith("workspace-a"); + expect(bindWorkspace).toHaveBeenCalledWith({ workspaceId: "workspace-a", cwd: runtimeCwd }); +}); diff --git a/packages/server/src/server/workspace-git-directory.ts b/packages/server/src/server/workspace-git-directory.ts new file mode 100644 index 0000000000..800e05426c --- /dev/null +++ b/packages/server/src/server/workspace-git-directory.ts @@ -0,0 +1,114 @@ +import { resolve } from "node:path"; +import type { WorkspaceGitService, WorkspaceGitWorkspace } from "./workspace-git-service.js"; +import { + type PersistedWorkspaceRecord, + resolveSelectedWorkspaceRuntimeId, + type WorkspaceRegistry, +} from "./workspace-registry.js"; + +export type WorkspaceGitAddress = + | { readonly kind: "selected"; readonly workspaceId: string; readonly cwd: string } + | { readonly kind: "legacy"; readonly cwd: string }; + +export interface WorkspaceGitDirectory { + resolve(address: WorkspaceGitAddress): Promise; + bindRecord( + record: Pick, + ): WorkspaceGitWorkspace; + getBound(workspaceId: string, cwd: string): WorkspaceGitWorkspace; + getObservationBinding( + workspaceId: string, + cwd: string, + ): { + address: WorkspaceGitAddress; + workspaceGit: WorkspaceGitWorkspace; + }; +} + +/** Commits request compatibility once, before ordinary Git callers receive a capability. */ +export function createWorkspaceGitDirectory(options: { + workspaceRegistry: Pick; + workspaceGitService: Pick; +}): WorkspaceGitDirectory { + const { workspaceRegistry, workspaceGitService } = options; + const boundRecords = new Map< + string, + { cwd: string; selected: boolean; workspaceGit: WorkspaceGitWorkspace } + >(); + + function bindRecord( + record: Pick, + ): WorkspaceGitWorkspace { + const selected = record.runtime?.runtimeId !== undefined; + const cwd = selected ? record.cwd.trim() : resolve(record.cwd); + const existing = boundRecords.get(record.workspaceId); + if (existing) { + if (existing.cwd !== cwd) + throw new Error(`Workspace Git binding changed cwd: ${record.workspaceId}`); + return existing.workspaceGit; + } + const workspaceGit = selected + ? workspaceGitService.bindWorkspace({ + workspaceId: record.workspaceId, + cwd, + }) + : workspaceGitService.bindLegacy(cwd); + boundRecords.set(record.workspaceId, { cwd, selected, workspaceGit }); + return workspaceGit; + } + + return { + bindRecord, + getBound(workspaceId, cwd) { + const bound = boundRecords.get(workspaceId); + const addressedCwd = bound?.selected ? cwd.trim() : resolve(cwd); + if (!bound || bound.cwd !== addressedCwd) { + throw new Error(`Workspace Git is not bound: ${workspaceId}`); + } + return bound.workspaceGit; + }, + getObservationBinding(workspaceId, cwd) { + const bound = boundRecords.get(workspaceId); + const addressedCwd = bound?.selected ? cwd.trim() : resolve(cwd); + if (!bound || bound.cwd !== addressedCwd) { + throw new Error(`Workspace Git is not bound: ${workspaceId}`); + } + return { + address: bound.selected + ? { kind: "selected", workspaceId, cwd: addressedCwd } + : { kind: "legacy", cwd: addressedCwd }, + workspaceGit: bound.workspaceGit, + }; + }, + async resolve(address) { + if (address.kind === "selected") { + const workspaceId = address.workspaceId.trim(); + if (workspaceId.length === 0) { + throw new Error("workspaceId is required for selected workspace Git"); + } + const selectedCwd = address.cwd.trim(); + if (selectedCwd.length === 0) { + throw new Error("cwd is required for selected workspace Git"); + } + const record = await workspaceRegistry.get(workspaceId); + if (!record) throw new Error(`Workspace not found: ${workspaceId}`); + if (record.cwd.trim() !== selectedCwd) { + throw new Error(`Workspace cwd does not match ${workspaceId}`); + } + return bindRecord(record); + } + + const cwd = resolve(address.cwd); + const runtimeOnlyAtCwd = (await workspaceRegistry.list()).some( + (record) => + resolveSelectedWorkspaceRuntimeId(record) !== null && + resolve(record.cwd) === cwd && + record.hostVisiblePath === null, + ); + 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-git-noop-fetch.local.e2e.test.ts b/packages/server/src/server/workspace-git-noop-fetch.local.e2e.test.ts index 4634daa8ce..54b6ebd1a3 100644 --- a/packages/server/src/server/workspace-git-noop-fetch.local.e2e.test.ts +++ b/packages/server/src/server/workspace-git-noop-fetch.local.e2e.test.ts @@ -174,6 +174,7 @@ async function measureFetchScenario( snapshotUpdates: number; }> { const fetchStarted = createDeferred(); + const fetchCompleted = createDeferred(); const releaseFetch = createDeferred(); let fetchCount = 0; let fetchChangedNonRemoteRefs: boolean | undefined; @@ -188,6 +189,7 @@ async function measureFetchScenario( const result = await fetchWorkspaceGitRemote(cwd, observer); fetchChangedNonRemoteRefs = result.nonRemoteRefsChanged; fetchCount += 1; + fetchCompleted.resolve(); return result; }, }, @@ -217,6 +219,7 @@ async function measureFetchScenario( startGitCommandMetrics(); releaseFetch.resolve(); await duringFetch?.(); + await fetchCompleted.promise; await waitForGitCommandMetricsIdle({ quietMs, timeoutMs: 120_000 }); const postFetch = stopGitCommandMetrics(); const snapshotUpdatesAfterFetch = [...snapshotCounts].reduce( diff --git a/packages/server/src/server/workspace-git-observation.ts b/packages/server/src/server/workspace-git-observation.ts new file mode 100644 index 0000000000..76d7f3927f --- /dev/null +++ b/packages/server/src/server/workspace-git-observation.ts @@ -0,0 +1,35 @@ +import type { BoundWorkspaceRuntime } from "./workspace-runtime/index.js"; +import { observeGitCommonMetadata } from "./workspace-runtime/git-observation/index.js"; + +export async function observeWorkspaceGit( + runtime: BoundWorkspaceRuntime, + listener: () => void, +): Promise<{ unsubscribe(): Promise }> { + let ready = false; + const workingTree = await runtime.files.subscribe( + { paths: ["."], recursive: true, ignoredPaths: [".git"] }, + (event) => { + if (ready && event.type !== "error") listener(); + }, + ); + + let commonRefs: { unsubscribe(): Promise } | null = null; + try { + commonRefs = await observeGitCommonMetadata(runtime, () => { + if (ready) listener(); + }); + ready = true; + } catch (error) { + await workingTree.unsubscribe(); + throw error; + } + + let closed = false; + return { + async unsubscribe() { + if (closed) return; + closed = true; + await Promise.all([workingTree.unsubscribe(), commonRefs?.unsubscribe()]); + }, + }; +} diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index eb54262b7b..d8c4bc04a5 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -1,21 +1,25 @@ +import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import type pino from "pino"; +import pino, { type Logger } from "pino"; import { afterEach, expect, test, vi } from "vitest"; import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { createFileObserver } from "./file-observer/index.js"; -import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; +import { + WorkspaceGitServiceImpl, + type WorkspaceGitRuntimeSnapshot, +} from "./workspace-git-service.js"; import type { FileChange, SubscribeToFileChanges } from "./file-observer/index.js"; -function createLogger(): pino.Logger { +function createLogger(): Logger { const logger = { child: () => logger, debug: vi.fn(), warn: vi.fn(), }; - return logger as unknown as pino.Logger; + return logger as unknown as Logger; } function createFacts(cwd: string): CheckoutSnapshotFacts { @@ -413,3 +417,77 @@ test("recursive observation updates tracked state and prunes ignored storms", as expect(getCheckoutDiff).not.toHaveBeenCalled(); expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0); }, 30_000); + +test("sibling worktrees share one common-repository observer with one observer per checkout", async () => { + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-shared-git-observation-"))); + const repoDir = path.join(tempDir, "repo"); + const paseoHome = path.join(tempDir, "paseo-home"); + const worktrees = Array.from({ length: 4 }, (_, index) => + path.join(tempDir, `worktree-${index}`), + ); + + execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repoDir, stdio: "pipe" }); + writeFileSync(path.join(repoDir, "tracked.txt"), "base\n"); + execFileSync("git", ["add", "tracked.txt"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repoDir, + stdio: "pipe", + }); + for (const [index, worktree] of worktrees.entries()) { + execFileSync("git", ["worktree", "add", "-b", `fixture-${index}`, worktree, "main"], { + cwd: repoDir, + stdio: "pipe", + }); + } + + const service = new WorkspaceGitServiceImpl({ + logger: pino({ level: "silent" }), + paseoHome, + }); + const updates = new Map( + worktrees.map((worktree) => [worktree, [] as WorkspaceGitRuntimeSnapshot["git"][]]), + ); + const subscriptions = worktrees.map((cwd) => + service.registerWorkspace({ cwd }, (snapshot) => { + updates.get(cwd)?.push(snapshot.git); + }), + ); + + try { + await vi.waitFor( + () => { + expect(service.getMetrics()).toMatchObject({ + workspaceTargetCount: 4, + repositoryTargetCount: 1, + repositoryWorkspaceLinkCount: 4, + workingTreeWatchTargetCount: 4, + workspaceObservationSetupInFlightCount: 0, + workingTreeWatchSetupInFlightCount: 0, + fileObserver: { activeObservationCount: 5 }, + }); + for (const worktree of worktrees) expect(service.peekSnapshot(worktree)).not.toBeNull(); + }, + { timeout: 15_000 }, + ); + + writeFileSync(path.join(worktrees[2]!, "tracked.txt"), "changed\n"); + await vi.waitFor( + () => { + expect(updates.get(worktrees[2]!)?.at(-1)).toMatchObject({ + isDirty: true, + diffStat: { additions: 1, deletions: 1 }, + }); + }, + { timeout: 15_000 }, + ); + } finally { + for (const subscription of subscriptions) subscription.unsubscribe(); + await service.dispose(); + rmSync(tempDir, { recursive: true, force: true }); + } +}, 30_000); diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index e863ebb4a0..09dc9274cd 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -24,7 +24,10 @@ interface WatchRecord { unsubscribe: ReturnType; } -function createWatcherHarness(harnessOptions?: { failDirectories?: Set }) { +function createWatcherHarness(harnessOptions?: { + failDirectories?: Set; + unsubscribe?: () => Promise; +}) { const records: WatchRecord[] = []; const subscribe = vi.fn( async ( @@ -39,7 +42,7 @@ function createWatcherHarness(harnessOptions?: { failDirectories?: Set } const record = records.find((candidate) => candidate.updateIgnore === updateIgnore); if (record) record.ignore = paths; }); - const unsubscribe = vi.fn(async () => {}); + const unsubscribe = vi.fn(harnessOptions?.unsubscribe ?? (async () => {})); records.push({ directory, callback, @@ -248,6 +251,42 @@ describe("WorkspaceGitService checkout observation", () => { await disposal; }); + test("dispose waits for checkout and repository watchers to finish closing", async () => { + const watcherCloseFinished = createDeferred(); + const watcher = createWatcherHarness({ + unsubscribe: () => watcherCloseFinished.promise, + }); + const fileObserver: FileObserver = { + subscribe: watcher.subscribe as FileObserver["subscribe"], + getDiagnostics: () => { + throw new Error("Diagnostics are not used by this lifecycle test"); + }, + close: async () => {}, + }; + const service = createService(watcher, undefined, createLogger(), fileObserver); + service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + await vi.waitFor(() => { + expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1); + expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(1); + }); + + const disposal = service.dispose(); + await vi.waitFor(() => { + for (const { unsubscribe } of watcher.records) { + expect(unsubscribe).toHaveBeenCalledTimes(1); + } + }); + expect( + await Promise.race([ + disposal.then(() => "disposed" as const), + Promise.resolve("pending" as const), + ]), + ).toBe("pending"); + + watcherCloseFinished.resolve(); + await disposal; + }); + test("shares one recursive checkout observer between cwd-equivalent consumers", async () => { const watcher = createWatcherHarness(); const runGitCommand = vi.fn(async (args: string[]) => ({ diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 4cccaa6e55..1eaf0cf3ae 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -623,10 +623,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { test("non-forced getSnapshot returns the current snapshot during an in-flight refresh", async () => { let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); const refreshStatus = createDeferred(); + const refreshStarted = createDeferred(); const getCheckoutStatus = vi .fn<(cwd: string) => Promise>() .mockImplementationOnce(async (cwd: string) => createCheckoutStatus(cwd)) .mockImplementationOnce(async () => { + refreshStarted.resolve(); const status = await refreshStatus.promise; return { ...status, currentBranch: "feature" }; }); @@ -649,7 +651,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { nowMs += 3_000; const refresh = service.refresh(REPO_CWD); - await flushPromises(); + await refreshStarted.promise; const directRead = service.getSnapshot(REPO_CWD); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); diff --git a/packages/server/src/server/workspace-git-service.runtime.integration.test.ts b/packages/server/src/server/workspace-git-service.runtime.integration.test.ts new file mode 100644 index 0000000000..8ec2a4c2a2 --- /dev/null +++ b/packages/server/src/server/workspace-git-service.runtime.integration.test.ts @@ -0,0 +1,959 @@ +import { execFileSync, spawn } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterEach, expect, test, vi } from "vitest"; + +import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; +import { observeWorkspaceGit } from "./workspace-git-observation.js"; +import { createWorkspaceRuntimeService } from "./workspace-runtime/index.js"; + +const fixtureExecutable = fileURLToPath( + new URL("../../../../runtimes/fixture/src/index.mjs", import.meta.url), +); +const cleanupRoots: string[] = []; +const cleanupTasks: Array<() => Promise> = []; + +function eventWithin(event: Promise, label: string, timeoutMs = 5_000): Promise { + return Promise.race([ + event, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), timeoutMs), + ), + ]); +} + +function createDeferred(): { promise: Promise; resolve(): void } { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +afterEach(async () => { + const failures: unknown[] = []; + for (const cleanup of cleanupTasks.splice(0).toReversed()) { + try { + await cleanup(); + } catch (error) { + failures.push(error); + } + } + const removalResults = await Promise.allSettled( + cleanupRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); + failures.push( + ...removalResults + .filter((result): result is PromiseRejectedResult => result.status === "rejected") + .map((result) => result.reason), + ); + if (failures.length > 0) { + throw new AggregateError(failures, "Runtime Git fixture cleanup failed"); + } +}); + +test.each(["local", "worktree"] as const)( + "selected %s workspace Git uses the same bound runtime journey", + async (runtimeId) => { + const root = await mkdtemp(path.join(tmpdir(), `paseo-${runtimeId}-git-`)); + cleanupRoots.push(root); + const source = await createRepository(path.join(root, "source")); + const publicAddress = path.join(root, "public-workspace-address"); + const workspaceId = `${runtimeId}-git-workspace`; + const runtimeIds = new Map(); + const workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + worktreesRoot: path.join(root, "worktrees"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, selectedRuntimeId) => { + runtimeIds.set(id, selectedRuntimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + }, + }); + await workspaceRuntime.create({ + workspaceId, + runtimeId, + project: { id: `${runtimeId}-project`, source: { kind: "host-directory", path: source } }, + placement: + runtimeId === "local" + ? { kind: "existing" } + : { + kind: "branch", + branchName: "slice-4-worktree", + baseRef: "main", + worktreeSlug: "slice-4-worktree", + }, + }); + const workspaceGit = new WorkspaceGitServiceImpl({ + logger: createLogger(), + paseoHome: path.join(root, "paseo-home"), + worktreesRoot: path.join(root, "worktrees"), + workspaceRuntime, + }); + const selectedGit = workspaceGit.bindWorkspace({ workspaceId, cwd: publicAddress }); + + await workspaceRuntime.files(workspaceId).write({ + path: "tracked.txt", + contents: Buffer.from(`${runtimeId} edit\n`), + }); + const dirty = await selectedGit.getSnapshot({ + force: true, + includeForge: false, + reason: `${runtimeId}-edit`, + }); + const diff = await selectedGit.getCheckoutDiff( + { mode: "uncommitted", includeStructured: true }, + { force: true, reason: `${runtimeId}-edit` }, + ); + expect(dirty.git).toMatchObject({ isGit: true, isDirty: true }); + expect(JSON.stringify(diff)).toContain(`${runtimeId} edit`); + + const originalBranch = runtimeId === "local" ? "main" : "slice-4-worktree"; + await selectedGit.commit({ message: `${runtimeId} commit`, addAll: true }); + await selectedGit.createBranch({ + branch: `${runtimeId}-next`, + baseRef: originalBranch, + }); + await selectedGit.switchBranch(originalBranch); + const clean = await selectedGit.getSnapshot(); + expect(clean.git).toMatchObject({ isDirty: false, currentBranch: originalBranch }); + + await workspaceGit.dispose(); + await workspaceRuntime.destroy(workspaceId); + }, + 20_000, +); + +test("workspace Git disposal waits for runtime observation teardown", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-dispose-")); + cleanupRoots.push(root); + const source = await createRepository(path.join(root, "source")); + const workspaceId = "runtime-git-dispose"; + const runtimeIds = new Map(); + const workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => { + runtimeIds.set(id, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + }, + }); + await workspaceRuntime.create({ + workspaceId, + runtimeId: "local", + project: { id: "dispose-project", source: { kind: "host-directory", path: source } }, + placement: { kind: "existing" }, + }); + + const boundRuntime = await workspaceRuntime.bind(workspaceId); + const originalSubscribe = boundRuntime.files.subscribe.bind(boundRuntime.files); + const unsubscribeStarted = createDeferred(); + const unsubscribeReleased = createDeferred(); + boundRuntime.files.subscribe = async (...args) => { + const subscription = await originalSubscribe(...args); + return { + unsubscribe: async () => { + unsubscribeStarted.resolve(); + await unsubscribeReleased.promise; + await subscription.unsubscribe(); + }, + }; + }; + + const workspaceGit = new WorkspaceGitServiceImpl({ + logger: createLogger(), + paseoHome: path.join(root, "paseo-home"), + workspaceRuntime, + }); + const selectedGit = workspaceGit.bindWorkspace({ workspaceId, cwd: source }); + await selectedGit.observe(() => undefined); + + const disposal = workspaceGit.dispose(); + try { + await eventWithin(unsubscribeStarted.promise, "runtime observation teardown"); + expect( + await Promise.race([ + disposal.then(() => "disposed" as const), + Promise.resolve("pending" as const), + ]), + ).toBe("pending"); + } finally { + unsubscribeReleased.resolve(); + } + await disposal; + await workspaceRuntime.destroy(workspaceId); +}); + +test.each([ + ["local", "pause", "resume"], + ["worktree", "archive", "restore"], +] as const)( + "sole selected %s common-ref observation survives %s/%s", + async (runtimeId, suspend, awaken) => { + const root = await mkdtemp(path.join(tmpdir(), `paseo-${runtimeId}-git-lifecycle-`)); + cleanupRoots.push(root); + const source = await createRepository(path.join(root, "source")); + const workspaceId = `${runtimeId}-lifecycle`; + const records = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + worktreesRoot: path.join(root, "worktrees"), + resolveRuntimeId: async (id) => records.get(id)?.runtimeId ?? null, + persistRuntimeId: async (id, selectedRuntimeId) => { + records.set(id, { runtimeId: selectedRuntimeId, archived: false }); + }, + archiveWorkspaceRecord: async (id) => { + const record = records.get(id); + if (record) record.archived = true; + }, + restoreWorkspaceRecord: async (id) => { + const record = records.get(id); + if (record) record.archived = false; + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + records.delete(id); + }, + }); + await service.create({ + workspaceId, + runtimeId, + project: { id: "lifecycle-project", source: { kind: "host-directory", path: source } }, + placement: + runtimeId === "local" + ? { kind: "existing" } + : { + kind: "branch", + branchName: "lifecycle-worktree", + baseRef: "main", + worktreeSlug: "lifecycle-worktree", + }, + }); + + let observeChanges = false; + let resolveChanged!: () => void; + const changed = new Promise((resolve) => { + resolveChanged = resolve; + }); + const subscription = await observeWorkspaceGit(await service.bind(workspaceId), () => { + if (observeChanges) resolveChanged(); + }); + + await service[suspend](workspaceId); + await service[awaken](workspaceId); + observeChanges = true; + await createBranchThroughRuntime(service, workspaceId, `${runtimeId}-after-lifecycle`); + await eventWithin(changed, `${runtimeId} common-ref update after lifecycle transition`); + + await subscription.unsubscribe(); + await service.destroy(workspaceId); + }, + 20_000, +); + +test("replayed common-ref observation survives runtime service reconstruction", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-git-observation-reconstruction-")); + cleanupRoots.push(root); + const source = await createRepository(path.join(root, "source")); + const workspaceId = "reconstructed-observation"; + const records = new Map(); + const options = { + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (id: string) => records.get(id)?.runtimeId ?? null, + persistRuntimeId: async (id: string, runtimeId: string) => { + records.set(id, { runtimeId, archived: false }); + }, + archiveWorkspaceRecord: async (id: string) => { + const record = records.get(id); + if (record) record.archived = true; + }, + restoreWorkspaceRecord: async (id: string) => { + const record = records.get(id); + if (record) record.archived = false; + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id: string) => { + records.delete(id); + }, + }; + const original = createWorkspaceRuntimeService(options); + await original.create({ + workspaceId, + runtimeId: "local", + project: { id: "reconstructed-project", source: { kind: "host-directory", path: source } }, + placement: { kind: "existing" }, + }); + const originalSubscription = await observeWorkspaceGit( + await original.bind(workspaceId), + () => undefined, + ); + await original.archive(workspaceId); + + const reconstructed = createWorkspaceRuntimeService(options); + await reconstructed.restore(workspaceId); + let resolveChanged!: () => void; + const changed = new Promise((resolve) => { + resolveChanged = resolve; + }); + const replayed = await observeWorkspaceGit(await reconstructed.bind(workspaceId), resolveChanged); + await createBranchThroughRuntime(reconstructed, workspaceId, "after-reconstruction"); + await eventWithin(changed, "common-ref update after reconstruction replay"); + + await originalSubscription.unsubscribe(); + await replayed.unsubscribe(); + await reconstructed.destroy(workspaceId); +}, 20_000); + +test("selected sibling worktrees retain common-ref fan-out across lifecycle transitions", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-fanout-")); + cleanupRoots.push(root); + const source = await createRepository(path.join(root, "source")); + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + worktreesRoot: path.join(root, "worktrees"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => { + runtimeIds.set(id, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + }, + }); + for (const [workspaceId, branchName] of [ + ["sibling-a", "sibling-a"], + ["sibling-b", "sibling-b"], + ] as const) { + await service.create({ + workspaceId, + runtimeId: "worktree", + project: { id: "fanout-project", source: { kind: "host-directory", path: source } }, + placement: { kind: "branch", branchName, baseRef: "main", worktreeSlug: branchName }, + }); + } + let resolveFirst!: () => void; + let resolveSibling!: () => void; + const firstChanged = new Promise((resolve) => { + resolveFirst = resolve; + }); + const siblingChanged = new Promise((resolve) => { + resolveSibling = resolve; + }); + let phase: "first-change" | "after-pause-change" | "after-destroy-change" = "first-change"; + let resolveSiblingAfterOwnerPause!: () => void; + const siblingChangedAfterOwnerPause = new Promise((resolve) => { + resolveSiblingAfterOwnerPause = resolve; + }); + let resolveSiblingAfterOwnerDestroy!: () => void; + const siblingChangedAfterOwnerDestroy = new Promise((resolve) => { + resolveSiblingAfterOwnerDestroy = resolve; + }); + const siblingA = await service.bind("sibling-a"); + const siblingB = await service.bind("sibling-b"); + const subscriptions = [ + await observeWorkspaceGit(siblingA, () => { + if (phase === "first-change") resolveFirst(); + }), + await observeWorkspaceGit(siblingB, () => { + if (phase === "first-change") resolveSibling(); + if (phase === "after-pause-change") resolveSiblingAfterOwnerPause(); + if (phase === "after-destroy-change") resolveSiblingAfterOwnerDestroy(); + }), + ]; + await createBranchThroughRuntime(service, "sibling-a", "shared-ref-change"); + await Promise.all([ + eventWithin(firstChanged, "first sibling ref update"), + eventWithin(siblingChanged, "second sibling ref update"), + ]); + await service.pause("sibling-a"); + phase = "after-pause-change"; + await createBranchThroughRuntime(service, "sibling-b", "shared-ref-after-owner-pause"); + await eventWithin(siblingChangedAfterOwnerPause, "sibling ref update after owner pause"); + await service.destroy("sibling-a"); + phase = "after-destroy-change"; + await createBranchThroughRuntime(service, "sibling-b", "shared-ref-after-owner-destroy"); + await eventWithin(siblingChangedAfterOwnerDestroy, "sibling ref update after owner destroy"); + await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe())); + await Promise.all([service.destroy("sibling-a"), service.destroy("sibling-b")]); +}, 20_000); + +test("sibling Git observations stay workspace-bound across pause, resume, and unsubscribe", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-watcher-race-")); + cleanupRoots.push(root); + const source = await createRepository(path.join(root, "source")); + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + worktreesRoot: path.join(root, "worktrees"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => { + runtimeIds.set(id, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + }, + }); + for (const workspaceId of ["race-a", "race-b"] as const) { + await service.create({ + workspaceId, + runtimeId: "worktree", + project: { id: "race-project", source: { kind: "host-directory", path: source } }, + placement: { + kind: "branch", + branchName: workspaceId, + baseRef: "main", + worktreeSlug: workspaceId, + }, + }); + } + + const [runtimeA, runtimeB] = await Promise.all([service.bind("race-a"), service.bind("race-b")]); + let changesA = 0; + let changesB = 0; + let resolveA!: () => void; + let resolveB!: () => void; + const nextA = () => + new Promise((resolve) => { + resolveA = resolve; + }); + const nextB = () => + new Promise((resolve) => { + resolveB = resolve; + }); + const subscriptions = await Promise.all([ + observeWorkspaceGit(runtimeA, () => { + changesA += 1; + resolveA?.(); + }), + observeWorkspaceGit(runtimeB, () => { + changesB += 1; + resolveB?.(); + }), + ]); + let eventA = nextA(); + let eventB = nextB(); + await createBranchThroughRuntime(service, "race-b", "race-both-active"); + await Promise.all([eventWithin(eventA, "sibling A"), eventWithin(eventB, "sibling B")]); + + await service.pause("race-a"); + const pausedChangesA = changesA; + eventB = nextB(); + await createBranchThroughRuntime(service, "race-b", "race-a-paused"); + await eventWithin(eventB, "active sibling while A is paused"); + expect(changesA).toBe(pausedChangesA); + + await service.resume("race-a"); + eventA = nextA(); + eventB = nextB(); + await createBranchThroughRuntime(service, "race-b", "race-a-resumed"); + await Promise.all([eventWithin(eventA, "resumed sibling A"), eventWithin(eventB, "sibling B")]); + + await subscriptions[0].unsubscribe(); + const unsubscribedChangesA = changesA; + eventB = nextB(); + await createBranchThroughRuntime(service, "race-b", "race-a-unsubscribed"); + await eventWithin(eventB, "remaining sibling B"); + expect(changesA).toBe(unsubscribedChangesA); + await subscriptions[1].unsubscribe(); + + await Promise.all([service.destroy("race-a"), service.destroy("race-b")]); +}, 20_000); + +async function createBranchThroughRuntime( + service: ReturnType, + workspaceId: string, + branch: string, +): Promise { + const runtime = await service.bind(workspaceId); + const gitExecutable = await runtime.resolveCommand("git"); + expect(gitExecutable).not.toBeNull(); + const childProcess = await service.run({ + workspaceId, + argv: [gitExecutable!, "branch", branch], + env: { PATH: process.env.PATH ?? "" }, + purpose: { kind: "git" }, + }); + childProcess.stdin.end(); + await expect(childProcess.exited).resolves.toEqual({ code: 0, signal: null }); +} + +test("selected workspace Git reads stay inside its command runtime", async () => { + const { hostDecoy, runtimeRepository, selectedGit, workspaceRuntime, workspaceId } = + await createCommandRuntimeGitFixture(); + await workspaceRuntime.files(workspaceId).write({ + path: "tracked.txt", + contents: Buffer.from("runtime edit\n"), + }); + await writeFile(path.join(hostDecoy, "tracked.txt"), "host edit\n"); + git(hostDecoy, "branch", "host-only"); + + const dirty = await selectedGit.getSnapshot({ + force: true, + includeForge: false, + reason: "runtime-edit", + }); + const diff = await selectedGit.getCheckoutDiff( + { mode: "uncommitted", includeStructured: true }, + { force: true, reason: "runtime-edit" }, + ); + expect(dirty.git).toMatchObject({ isGit: true, isDirty: true, currentBranch: "main" }); + expect(dirty.git.repoRoot).toBe(hostDecoy); + expect(JSON.stringify(dirty)).not.toContain(runtimeRepository); + expect(JSON.stringify(diff)).toContain("runtime edit"); + expect(JSON.stringify(diff)).not.toContain("host edit"); +}, 20_000); + +test("selected workspace Git rejects mutations unsupported by its command runtime", async () => { + const { hostDecoy, selectedGit } = await createCommandRuntimeGitFixture(); + git(hostDecoy, "branch", "host-only"); + await expect(selectedGit.switchBranch("host-only")).rejects.toThrow( + "Branch not found: host-only", + ); + await expect(selectedGit.mergeToBase({ baseRef: "main" })).rejects.toThrow( + "Selected workspace Git does not support merge to base", + ); + await expect(selectedGit.mergeFromBase({ baseRef: "main" })).rejects.toThrow( + "Selected workspace Git does not support merge from base", + ); + await expect(selectedGit.renameBranch("selected-rename")).rejects.toThrow( + "Selected workspace Git does not support branch rename", + ); + await expect(selectedGit.push()).rejects.toThrow("Selected workspace Git does not support push"); +}, 20_000); + +test("selected workspace Git stash stays inside its command runtime", async () => { + const { hostDecoy, selectedGit, workspaceRuntime, workspaceId } = + await createCommandRuntimeGitFixture(); + await workspaceRuntime.files(workspaceId).write({ + path: "tracked.txt", + contents: Buffer.from("runtime edit\n"), + }); + await writeFile(path.join(hostDecoy, "tracked.txt"), "host edit\n"); + await selectedGit.stashPush("paseo-runtime-stash"); + expect((await selectedGit.getSnapshot({ force: true, reason: "stash-push" })).git.isDirty).toBe( + false, + ); + await selectedGit.stashPop(0); + expect((await selectedGit.getSnapshot({ force: true, reason: "stash-pop" })).git.isDirty).toBe( + true, + ); + expect(await readFile(path.join(hostDecoy, "tracked.txt"), "utf8")).toBe("host edit\n"); +}, 25_000); + +test("selected workspace Git commit and branch stay inside its command runtime", async () => { + const { hostDecoy, runtimeRepository, selectedGit, workspaceRuntime, workspaceId } = + await createCommandRuntimeGitFixture(); + await workspaceRuntime.files(workspaceId).write({ + path: "tracked.txt", + contents: Buffer.from("runtime edit\n"), + }); + await writeFile(path.join(hostDecoy, "tracked.txt"), "host edit\n"); + await selectedGit.commit({ message: "runtime commit", addAll: true }); + await selectedGit.createBranch({ branch: "runtime-branch", baseRef: "main" }); + await selectedGit.switchBranch("main"); + const clean = await selectedGit.getSnapshot({ + force: true, + includeForge: false, + reason: "runtime-mutations", + }); + + expect(clean.git).toMatchObject({ isDirty: false, currentBranch: "main" }); + expect(await readFile(path.join(runtimeRepository, "tracked.txt"), "utf8")).toBe( + "runtime edit\n", + ); + expect(await readFile(path.join(hostDecoy, "tracked.txt"), "utf8")).toBe("host edit\n"); + expect(git(runtimeRepository, "log", "-1", "--format=%s")).toBe("runtime commit"); + expect(git(runtimeRepository, "branch", "--list", "runtime-branch")).toBe("runtime-branch"); +}, 30_000); + +test("selected workspace Git fetch stays inside its command runtime", async () => { + const { root, runtimeRepository, selectedGit } = await createCommandRuntimeGitFixture(); + const remoteRepository = path.join(root, "remote.git"); + await mkdir(remoteRepository); + git(remoteRepository, "init", "--bare", "--initial-branch=main"); + git(runtimeRepository, "remote", "add", "origin", remoteRepository); + git(runtimeRepository, "push", "-u", "origin", "main"); + const upstream = path.join(root, "upstream"); + execFileSync("git", ["clone", remoteRepository, upstream], { stdio: "pipe" }); + git(upstream, "config", "user.email", "test@example.com"); + git(upstream, "config", "user.name", "Paseo Test"); + await writeFile(path.join(upstream, "upstream.txt"), "upstream\n"); + git(upstream, "add", "."); + git(upstream, "commit", "-m", "upstream commit"); + git(upstream, "push", "origin", "main"); + await selectedGit.fetch(); + const fetched = await selectedGit.getSnapshot(); + expect(fetched.git.behindOfOrigin).toBe(1); +}, 25_000); + +test("selected workspace Git reconstructs command runtime state after recreation", async () => { + const { recreate, runtimeRepository, selectedGit, workspaceRuntime, workspaceId } = + await createCommandRuntimeGitFixture(); + await workspaceRuntime.destroy(workspaceId); + git(runtimeRepository, "branch", "-m", "runtime-rebuilt"); + await recreate(); + const reconstructed = await selectedGit.getSnapshot(); + expect(reconstructed.git.currentBranch).toBe("runtime-rebuilt"); +}, 20_000); + +test("selected commit history highlighting never reads a deleted file from the host cwd", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-history-")); + cleanupRoots.push(root); + const runtimeRepository = await createRepository(path.join(root, "runtime-repository")); + await writeFile(path.join(runtimeRepository, "deleted.ts"), "export const runtimeOnly = 1;\n"); + git(runtimeRepository, "add", "deleted.ts"); + git(runtimeRepository, "commit", "-m", "add runtime history file"); + await rm(path.join(runtimeRepository, "deleted.ts")); + git(runtimeRepository, "add", "deleted.ts"); + git(runtimeRepository, "commit", "-m", "delete runtime history file"); + const deletionSha = git(runtimeRepository, "rev-parse", "HEAD"); + const hostDecoy = await createRepository(path.join(root, "host-decoy")); + const hostTrap = path.join(hostDecoy, "deleted.ts"); + execFileSync("mkfifo", [hostTrap]); + + const stateDirectory = path.join(root, "runtime-state"); + await mkdir(stateDirectory); + const runtimeIds = new Map(); + const workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => { + runtimeIds.set(id, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + }, + externalRuntimes: { + fixture: { + type: "command", + command: [process.execPath, fixtureExecutable], + options: { stateDirectory, recordLaunchInWorkspace: false }, + }, + }, + }); + await workspaceRuntime.create({ + workspaceId: "history-workspace", + runtimeId: "fixture", + project: { id: "history-project", source: { kind: "host-directory", path: runtimeRepository } }, + placement: { kind: "existing" }, + }); + const service = new WorkspaceGitServiceImpl({ + logger: createLogger(), + paseoHome: path.join(root, "paseo-home"), + workspaceRuntime, + }); + const selectedGit = service.bindWorkspace({ + workspaceId: "history-workspace", + cwd: hostDecoy, + }); + const hostWriter = spawn( + process.execPath, + [ + "-e", + "setTimeout(() => require('node:fs').writeFileSync('deleted.ts', 'host trap\\\\n'), 60000)", + ], + { cwd: hostDecoy, stdio: "ignore" }, + ); + + const file = await selectedGit.getCommitFileDiff({ sha: deletionSha, path: "deleted.ts" }); + await new Promise((resolve) => setImmediate(resolve)); + expect(file?.isDeleted).toBe(true); + expect(hostWriter.exitCode).toBeNull(); + + hostWriter.kill("SIGKILL"); + await new Promise((resolve) => hostWriter.once("exit", () => resolve())); + await service.dispose(); + await workspaceRuntime.destroy("history-workspace"); +}, 20_000); + +test("selected workspaces with the same public cwd keep snapshots and diffs isolated", async () => { + const { workspaceA, workspaceB, workspaceRuntime } = await createSamePublicCwdFixture(); + await workspaceRuntime.files("same-cwd-a").write({ + path: "tracked.txt", + contents: Buffer.from("runtime a dirty\n"), + }); + const runtimeA = await workspaceRuntime.bind("same-cwd-a"); + const nodeExecutable = await runtimeA.resolveCommand("node"); + expect(nodeExecutable).not.toBeNull(); + const createUntracked = await workspaceRuntime.run({ + workspaceId: "same-cwd-a", + argv: [ + nodeExecutable!, + "-e", + "require('node:fs').writeFileSync('selected-proof.txt', 'runtime a untracked\\\\n')", + ], + env: {}, + purpose: { kind: "git" }, + }); + createUntracked.stdin.end(); + await expect(createUntracked.exited).resolves.toEqual({ code: 0, signal: null }); + const [snapshotA, snapshotB, diffA, diffB] = await Promise.all([ + workspaceA.getSnapshot({ force: true, includeForge: false, reason: "same-cwd-a" }), + workspaceB.getSnapshot({ force: true, includeForge: false, reason: "same-cwd-b" }), + workspaceA.getCheckoutDiff( + { mode: "uncommitted", includeStructured: true }, + { force: true, reason: "same-cwd-a" }, + ), + workspaceB.getCheckoutDiff( + { mode: "uncommitted", includeStructured: true }, + { force: true, reason: "same-cwd-b" }, + ), + ]); + expect(snapshotA.git).toMatchObject({ currentBranch: "main", isDirty: true }); + expect(snapshotB.git).toMatchObject({ currentBranch: "runtime-b", isDirty: false }); + expect(JSON.stringify(diffA)).toContain("runtime a dirty"); + expect(JSON.stringify(diffA)).toContain("runtime a untracked"); + expect(JSON.stringify(diffB)).not.toContain("runtime a dirty"); + const removeUntracked = await workspaceRuntime.run({ + workspaceId: "same-cwd-a", + argv: [ + nodeExecutable!, + "-e", + "require('node:fs').rmSync('selected-proof.txt', { force: true })", + ], + env: {}, + purpose: { kind: "git" }, + }); + removeUntracked.stdin.end(); + await expect(removeUntracked.exited).resolves.toEqual({ code: 0, signal: null }); +}, 25_000); + +test("selected workspaces with the same public cwd keep observations isolated", async () => { + const { workspaceRuntime } = await createSamePublicCwdFixture(); + const [runtimeA, runtimeB] = await Promise.all([ + workspaceRuntime.bind("same-cwd-a"), + workspaceRuntime.bind("same-cwd-b"), + ]); + const gitObservation = await observeWorkspaceGit(runtimeA, () => undefined); + await gitObservation.unsubscribe(); + let workspaceAChanges = 0; + let workspaceBChanges = 0; + let resolveObservedA!: () => void; + const observedA = new Promise((resolve) => { + resolveObservedA = resolve; + }); + const [observationA, observationB] = await Promise.all([ + runtimeA.files.subscribe({ paths: ["tracked.txt"] }, (event) => { + if (event.type !== "changed" || !event.paths.includes("tracked.txt")) return; + workspaceAChanges += 1; + resolveObservedA(); + }), + runtimeB.files.subscribe({ paths: ["tracked.txt"] }, (event) => { + if (event.type === "changed" && event.paths.includes("tracked.txt")) { + workspaceBChanges += 1; + } + }), + ]); + + try { + await workspaceRuntime.files("same-cwd-a").write({ + path: "tracked.txt", + contents: Buffer.from("runtime a watched\n"), + }); + await eventWithin(observedA, "workspace A change"); + await new Promise((resolve) => setImmediate(resolve)); + expect(workspaceAChanges).toBeGreaterThan(0); + expect(workspaceBChanges).toBe(0); + } finally { + await Promise.all([observationA.unsubscribe(), observationB.unsubscribe()]); + } +}, 20_000); + +test("selected workspaces with the same public cwd keep mutations and caches isolated", async () => { + const { + publicCwd, + recreateA, + runtimeARepository, + runtimeBRepository, + workspaceA, + workspaceB, + workspaceRuntime, + } = await createSamePublicCwdFixture(); + await workspaceRuntime.files("same-cwd-a").write({ + path: "tracked.txt", + contents: Buffer.from("runtime a commit\n"), + }); + await Promise.all([ + workspaceA.commit({ message: "runtime a commit", addAll: true }), + workspaceB.createBranch({ branch: "runtime-b-next", baseRef: "runtime-b" }), + ]); + const [mutatedA, mutatedB] = await Promise.all([ + workspaceA.getSnapshot({ force: true, includeForge: false, reason: "mutated-a" }), + workspaceB.getSnapshot({ force: true, includeForge: false, reason: "mutated-b" }), + ]); + expect(mutatedA.git).toMatchObject({ currentBranch: "main", isDirty: false }); + expect(mutatedB.git).toMatchObject({ currentBranch: "runtime-b-next", isDirty: false }); + expect(git(runtimeARepository, "log", "-1", "--format=%s")).toBe("runtime a commit"); + expect(git(runtimeARepository, "branch", "--list", "runtime-b-next")).toBe(""); + expect(git(runtimeBRepository, "branch", "--show-current")).toBe("runtime-b-next"); + expect(git(publicCwd, "branch", "--show-current")).toBe("host-decoy"); + + await workspaceRuntime.destroy("same-cwd-a"); + git(runtimeARepository, "branch", "-m", "runtime-a-rebuilt"); + await recreateA(); + const [rebuiltA, cachedB] = await Promise.all([ + workspaceA.getSnapshot(), + workspaceB.getSnapshot(), + ]); + expect(rebuiltA.git.currentBranch).toBe("runtime-a-rebuilt"); + expect(cachedB.git.currentBranch).toBe("runtime-b-next"); +}, 30_000); + +async function createCommandRuntimeGitFixture() { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-")); + cleanupRoots.push(root); + const runtimeRepository = await createRepository(path.join(root, "runtime-repository")); + const hostDecoy = await createRepository(path.join(root, "host-decoy")); + const stateDirectory = path.join(root, "runtime-state"); + await mkdir(stateDirectory); + const workspaceId = "runtime-git-workspace"; + const runtimeIds = new Map(); + const workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => { + runtimeIds.set(id, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + }, + externalRuntimes: { + fixture: { + type: "command", + command: [process.execPath, fixtureExecutable], + options: { stateDirectory, recordLaunchInWorkspace: false }, + }, + }, + }); + cleanupTasks.push(() => workspaceRuntime.close()); + const recreate = () => + workspaceRuntime.create({ + workspaceId, + runtimeId: "fixture", + project: { + id: "runtime-git-project", + source: { kind: "host-directory", path: runtimeRepository }, + }, + placement: { kind: "existing" }, + }); + await recreate(); + const service = new WorkspaceGitServiceImpl({ + logger: createLogger(), + paseoHome: path.join(root, "paseo-home"), + workspaceRuntime, + }); + cleanupTasks.push(() => service.dispose()); + const selectedGit = service.bindWorkspace({ workspaceId, cwd: hostDecoy }); + return { + root, + hostDecoy, + recreate, + runtimeRepository, + selectedGit, + workspaceId, + workspaceRuntime, + }; +} + +async function createSamePublicCwdFixture() { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-same-cwd-")); + cleanupRoots.push(root); + const runtimeARepository = await createRepository(path.join(root, "runtime-a")); + const runtimeBRepository = await createRepository(path.join(root, "runtime-b")); + git(runtimeBRepository, "branch", "-m", "runtime-b"); + const publicCwd = await createRepository(path.join(root, "shared-public-cwd")); + git(publicCwd, "branch", "-m", "host-decoy"); + await writeFile(path.join(publicCwd, "selected-proof.txt"), Buffer.alloc(32, 0)); + const stateDirectory = path.join(root, "runtime-state"); + await mkdir(stateDirectory); + const runtimeIds = new Map(); + const workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => { + runtimeIds.set(id, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + }, + externalRuntimes: { + fixture: { + type: "command", + command: [process.execPath, fixtureExecutable], + options: { stateDirectory, recordLaunchInWorkspace: false }, + }, + }, + }); + cleanupTasks.push(() => workspaceRuntime.close()); + const createWorkspace = (workspaceId: string, source: string) => + workspaceRuntime.create({ + workspaceId, + runtimeId: "fixture", + project: { id: workspaceId, source: { kind: "host-directory", path: source } }, + placement: { kind: "existing" }, + }); + await Promise.all([ + createWorkspace("same-cwd-a", runtimeARepository), + createWorkspace("same-cwd-b", runtimeBRepository), + ]); + const service = new WorkspaceGitServiceImpl({ + logger: createLogger(), + paseoHome: path.join(root, "paseo-home"), + workspaceRuntime, + deps: { getWorkspaceGitSelfHealPhaseMs: () => 60_000 }, + }); + cleanupTasks.push(() => service.dispose()); + return { + publicCwd, + recreateA: () => createWorkspace("same-cwd-a", runtimeARepository), + runtimeARepository, + runtimeBRepository, + workspaceA: service.bindWorkspace({ workspaceId: "same-cwd-a", cwd: publicCwd }), + workspaceB: service.bindWorkspace({ workspaceId: "same-cwd-b", cwd: publicCwd }), + workspaceRuntime, + }; +} + +async function createRepository(directory: string): Promise { + await mkdir(directory); + git(directory, "init", "-b", "main"); + git(directory, "config", "user.email", "test@example.com"); + git(directory, "config", "user.name", "Paseo Test"); + await writeFile(path.join(directory, ".git", "info", "exclude"), ".runtime-launch.json\n"); + await writeFile(path.join(directory, "tracked.txt"), "initial\n"); + git(directory, "add", "."); + git(directory, "commit", "-m", "initial"); + return directory; +} + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", ["-c", "commit.gpgsign=false", ...args], { + cwd, + encoding: "utf8", + }).trim(); +} + +function createLogger() { + const logger = { + child: () => logger, + debug: vi.fn(), + info: vi.fn(), + trace: vi.fn(), + warn: vi.fn(), + }; + return logger; +} diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 6b39ddc1b7..2de18807f2 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -10,6 +10,7 @@ import type { CheckoutContext } from "../utils/checkout-git.js"; import { type BranchCheckoutResolution, type BranchSuggestion, + type CheckoutExistingBranchResult, type CheckoutSnapshotFacts, type CheckoutDiffCompare, type CheckoutDiffResult, @@ -26,6 +27,16 @@ import { resolveRepositoryDefaultBranch, resolveBranchCheckout, resolveAbsoluteGitDir, + checkoutResolvedBranch, + commitChanges, + discardChanges, + getCommitFileDiff, + listCheckoutCommits, + mergeFromBase, + mergeToBase, + pullCurrentBranch, + pushCurrentBranch, + renameCurrentBranch, } from "../utils/checkout-git.js"; import type { ForgeAuthState, @@ -45,7 +56,12 @@ import { getRealpathAwareRelativePath, isRealpathInsideRoot, } from "../utils/path.js"; -import { runGitCommand } from "../utils/run-git-command.js"; +import { + runGitCommand, + runWithGitCommandRunner, + type GitCommandOptions, + type GitCommandResult, +} from "../utils/run-git-command.js"; import { branchNameFromRef } from "../utils/worktree-metadata.js"; import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js"; import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js"; @@ -68,6 +84,8 @@ import { createFileObserver, } from "./file-observer/index.js"; import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js"; +import type { BoundWorkspaceRuntime, WorkspaceRuntimeService } from "./workspace-runtime/index.js"; +import { observeWorkspaceGit } from "./workspace-git-observation.js"; import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js"; const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000; @@ -175,6 +193,8 @@ export interface WorkspaceGitRuntimeSnapshot { } export interface WorkspaceGitService { + bindWorkspace(input: { workspaceId: string; cwd: string }): WorkspaceGitWorkspace; + bindLegacy(cwd: string): WorkspaceGitWorkspace; registerWorkspace( params: { cwd: string }, listener: WorkspaceGitListener, @@ -217,6 +237,26 @@ export interface WorkspaceGitService { resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise; resolveDefaultBranch(cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions): Promise; resolveRepoRemoteUrl(cwd: string, options?: WorkspaceGitReadOptions): Promise; + commit(cwd: string, options: { message: string; addAll: boolean }): Promise; + discardChanges(cwd: string, paths: string[]): Promise; + createBranch(cwd: string, options: { branch: string; baseRef: string }): Promise; + switchBranch(cwd: string, branch: string): Promise; + fetch(cwd: string): Promise; + listCommits(cwd: string): ReturnType; + getCommitFileDiff( + cwd: string, + input: { sha: string; path: string }, + ): ReturnType; + stashPush(cwd: string, message: string): Promise; + stashPop(cwd: string, stashIndex: number): Promise; + mergeToBase(cwd: string, options: Parameters[1]): Promise; + mergeFromBase(cwd: string, options: Parameters[1]): Promise; + pull(cwd: string): Promise; + push(cwd: string): Promise; + renameBranch( + cwd: string, + branch: string, + ): Promise<{ previousBranch: string | null; currentBranch: string | null }>; refresh(cwd: string, options?: { priority?: "normal" | "high" }): Promise; requestWorkingTreeWatch( cwd: string, @@ -229,6 +269,62 @@ export interface WorkspaceGitService { dispose(): Promise; } +/** Git capability bound once to a selected workspace's durable identity. */ +export interface WorkspaceGitWorkspace { + readonly cwd: string; + register(listener: WorkspaceGitListener): WorkspaceGitSubscription; + observe(listener: WorkspaceGitListener): Promise; + peekSnapshot(): WorkspaceGitRuntimeSnapshot | null; + getCheckout(): Promise; + getSnapshot(options?: WorkspaceGitSnapshotOptions): Promise; + resolveForge(): Promise; + getCheckoutDiff( + options: CheckoutDiffCompare, + readOptions?: WorkspaceGitReadOptions, + ): Promise; + validateBranchRef( + ref: string, + options?: WorkspaceGitReadOptions, + ): Promise; + hasLocalBranch(branch: string, options?: WorkspaceGitReadOptions): Promise; + suggestBranches( + options?: WorkspaceGitBranchSuggestionsOptions, + readOptions?: WorkspaceGitReadOptions, + ): Promise; + listStashes( + options?: WorkspaceGitStashListOptions, + readOptions?: WorkspaceGitReadOptions, + ): Promise; + listWorktrees(options?: WorkspaceGitReadOptions): Promise; + getProjectSlug(options?: WorkspaceGitReadOptions): Promise; + resolveRepoRoot(options?: WorkspaceGitReadOptions): Promise; + resolveDefaultBranch(options?: WorkspaceGitReadOptions): Promise; + resolveRepoRemoteUrl(options?: WorkspaceGitReadOptions): Promise; + commit(options: { message: string; addAll: boolean }): Promise; + discardChanges(paths: string[]): Promise; + createBranch(options: { branch: string; baseRef: string }): Promise; + switchBranch(branch: string): Promise; + fetch(): Promise; + listCommits(): ReturnType; + getCommitFileDiff(input: { sha: string; path: string }): ReturnType; + stashPush(message: string): Promise; + stashPop(stashIndex: number): Promise; + mergeToBase(options: Parameters[1]): Promise; + mergeFromBase(options: Parameters[1]): Promise; + pull(): Promise; + push(): Promise; + renameBranch( + branch: string, + ): Promise<{ previousBranch: string | null; currentBranch: string | null }>; + refresh(options?: { priority?: "normal" | "high" }): Promise; + requestWorkingTreeWatch( + onChange: () => void, + ): Promise<{ repoRoot: string | null; unsubscribe: () => void }>; + scheduleRefresh(): void; + stateMayHaveChanged(): void; + invalidateForge(): void; +} + export interface WorkspaceGitServiceMetrics { workspaceTargetCount: number; workspaceListenerCount: number; @@ -363,14 +459,20 @@ interface WorkspaceGitServiceDependencies { now: () => Date; } -interface WorkspaceGitServiceOptions { +export interface WorkspaceGitServiceOptions { logger: pino.Logger; paseoHome: string; worktreesRoot?: string; fileObserver?: FileObserver; + workspaceRuntime?: WorkspaceRuntimeService; deps?: Partial; } +interface WorkspaceGitServiceBinding { + workspaceId: string; + snapshotUpdatedListeners: Set; +} + class WorkspaceGitServiceDisposedError extends Error { constructor() { super("WorkspaceGitService is disposed"); @@ -389,6 +491,8 @@ class WorkspaceGitWatcherSubscriptionTimeoutError extends Error { interface WorkspaceGitTarget { cwd: string; + runtimeIdentity: BoundWorkspaceRuntime | null; + runtimeObservation: { unsubscribe(): Promise } | null; listeners: Set; workingTreeWatchTarget: WorkingTreeWatchTarget | null; debounceTimer: NodeJS.Timeout | null; @@ -524,6 +628,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly fileObserver: FileObserver; private readonly deps: WorkspaceGitServiceDependencies; private readonly forgeResolver: ForgeResolver; + private readonly workspaceRuntime: WorkspaceRuntimeService | null; + private readonly selectedWorkspaceId: string | null; + private readonly selectedWorkspaces = new Map< + string, + { cwd: string; service: WorkspaceGitServiceImpl; capability: WorkspaceGitWorkspace } + >(); + private readonly legacyWorkspaces = new Map(); + private readonly runtimeCacheTokens = new WeakMap(); + private nextRuntimeCacheToken = 1; private readonly workspaceRefreshLimit = pLimit({ concurrency: WORKSPACE_GIT_REFRESH_CONCURRENCY, rejectOnClear: true, @@ -535,7 +648,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly disposeController = new AbortController(); private disposed = false; private disposePromise: Promise | null = null; - private readonly snapshotUpdatedListeners = new Set(); + private readonly snapshotUpdatedListeners: Set; + private readonly ownsSnapshotUpdatedListeners: boolean; private readonly workspaceTargets = new Map(); private readonly repoTargets = new Map(); private readonly workingTreeWatchTargets = new Map(); @@ -571,11 +685,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { WorkspaceGitAuxiliaryReadCacheEntry >({ max: WORKSPACE_GIT_CHECKOUT_DIFF_CACHE_MAX }); private watcherErrorCallbackCount = 0; - constructor(options: WorkspaceGitServiceOptions) { + constructor(options: WorkspaceGitServiceOptions, binding?: WorkspaceGitServiceBinding) { this.logger = options.logger.child({ module: "workspace-git-service" }); this.paseoHome = options.paseoHome; this.worktreesRoot = options.worktreesRoot; this.fileObserver = options.fileObserver ?? createFileObserver(); + this.workspaceRuntime = options.workspaceRuntime ?? null; + this.selectedWorkspaceId = binding?.workspaceId ?? null; + this.snapshotUpdatedListeners = + binding?.snapshotUpdatedListeners ?? new Set(); + this.ownsSnapshotUpdatedListeners = binding === undefined; this.deps = resolveWorkspaceGitServiceDeps( this.fileObserver.subscribe.bind(this.fileObserver), options.deps, @@ -585,6 +704,112 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }); } + bindWorkspace(input: { workspaceId: string; cwd: string }): WorkspaceGitWorkspace { + this.assertNotDisposed(); + if (!this.workspaceRuntime) { + throw new Error("Workspace runtime is not available"); + } + const cwd = resolve(input.cwd); + const existing = this.selectedWorkspaces.get(input.workspaceId); + if (existing) { + if (existing.cwd !== cwd) { + throw new Error(`Workspace Git binding changed cwd: ${input.workspaceId}`); + } + return existing.capability; + } + const service = new WorkspaceGitServiceImpl( + { + logger: this.logger, + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + workspaceRuntime: this.workspaceRuntime, + deps: this.deps, + }, + { + workspaceId: input.workspaceId, + snapshotUpdatedListeners: this.snapshotUpdatedListeners, + }, + ); + const capability = this.createBoundWorkspaceCapability(service, cwd, true); + this.selectedWorkspaces.set(input.workspaceId, { cwd, service, capability }); + return capability; + } + + bindLegacy(cwd: string): WorkspaceGitWorkspace { + this.assertNotDisposed(); + const normalizedCwd = resolve(cwd); + let capability = this.legacyWorkspaces.get(normalizedCwd); + if (!capability) { + capability = this.createBoundWorkspaceCapability(this, normalizedCwd, false); + this.legacyWorkspaces.set(normalizedCwd, capability); + } + return capability; + } + + private createBoundWorkspaceCapability( + service: WorkspaceGitServiceImpl, + cwd: string, + selected: boolean, + ): WorkspaceGitWorkspace { + const unsupported = (operation: string) => + Promise.reject(new Error(`Selected workspace Git does not support ${operation}`)); + return { + cwd, + register: (listener) => service.registerWorkspace({ cwd }, listener), + observe: (listener) => service.observeWorkspace(cwd, listener), + peekSnapshot: () => service.peekSnapshot(cwd), + getCheckout: () => service.getCheckout(cwd), + getSnapshot: (options) => service.getSnapshot(cwd, options), + resolveForge: () => (selected ? unsupported("forge resolution") : service.resolveForge(cwd)), + getCheckoutDiff: (options, readOptions) => service.getCheckoutDiff(cwd, options, readOptions), + validateBranchRef: (ref, options) => service.validateBranchRef(cwd, ref, options), + hasLocalBranch: (branch, options) => service.hasLocalBranch(cwd, branch, options), + suggestBranches: (options, readOptions) => + service.suggestBranchesForCwd(cwd, options, readOptions), + listStashes: (options, readOptions) => service.listStashes(cwd, options, readOptions), + listWorktrees: (options) => + selected ? unsupported("worktree listing") : service.listWorktrees(cwd, options), + getProjectSlug: (options) => service.getProjectSlug(cwd, options), + resolveRepoRoot: (options) => service.resolveRepoRoot(cwd, options), + resolveDefaultBranch: (options) => service.resolveDefaultBranch(cwd, options), + resolveRepoRemoteUrl: (options) => service.resolveRepoRemoteUrl(cwd, options), + commit: (options) => service.commit(cwd, options), + discardChanges: (paths) => service.discardChanges(cwd, paths), + createBranch: (options) => service.createBranch(cwd, options), + switchBranch: (branch) => service.switchBranch(cwd, branch), + fetch: () => service.fetch(cwd), + listCommits: () => service.listCommits(cwd), + getCommitFileDiff: (input) => service.getCommitFileDiff(cwd, input), + stashPush: (message) => service.stashPush(cwd, message), + stashPop: (stashIndex) => service.stashPop(cwd, stashIndex), + mergeToBase: (options) => + selected ? unsupported("merge to base") : service.mergeToBase(cwd, options), + mergeFromBase: (options) => + selected ? unsupported("merge from base") : service.mergeFromBase(cwd, options), + pull: () => service.pull(cwd), + push: () => (selected ? unsupported("push") : service.push(cwd)), + renameBranch: (branch) => + selected ? unsupported("branch rename") : service.renameBranch(cwd, branch), + refresh: async (options) => { + if (!selected) { + (await service.resolveForge(cwd))?.service.invalidate({ cwd }); + } + await service.fetch(cwd); + await service.getSnapshot(cwd, { + force: true, + includeForge: !selected, + reason: options?.priority === "high" ? "manual-refresh-high" : "manual-refresh", + }); + }, + requestWorkingTreeWatch: (onChange) => service.requestWorkingTreeWatch(cwd, onChange), + scheduleRefresh: () => service.scheduleRefreshForCwd(cwd), + stateMayHaveChanged: () => service.onWorkspaceStateMayHaveChanged(cwd), + invalidateForge: () => { + if (!selected) service.invalidateForge(cwd); + }, + }; + } + resolveForge(cwd: string): Promise { this.assertNotDisposed(); return this.forgeResolver.resolve(resolve(cwd)); @@ -613,6 +838,21 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }; } + private async observeWorkspace( + cwd: string, + listener: WorkspaceGitListener, + ): Promise { + const subscription = this.registerWorkspace({ cwd }, listener); + const target = this.workspaceTargets.get(resolve(cwd)); + try { + await target?.observationSetupPromise; + return subscription; + } catch (error) { + subscription.unsubscribe(); + throw error; + } + } + onSnapshotUpdated(listener: WorkspaceGitSnapshotUpdatedListener): WorkspaceGitSubscription { this.assertNotDisposed(); this.snapshotUpdatedListeners.add(listener); @@ -683,9 +923,26 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { options?: WorkspaceGitSnapshotOptions, ): Promise { this.assertNotDisposed(); + if (this.selectedWorkspaceId && options?.includeForge === true) { + throw new Error("Selected workspace Git does not support forge refresh"); + } cwd = resolve(cwd); + const runtime = this.selectedWorkspaceId ? await this.resolveBoundRuntime() : null; + const runtimeIdentity = runtime?.runtime ?? null; const request = this.normalizeRefreshRequest(options, "getSnapshot", true); const target = this.ensureWorkspaceTarget(cwd); + if (target.runtimeIdentity !== runtimeIdentity) { + void target.runtimeObservation?.unsubscribe(); + target.runtimeObservation = null; + target.observationSetupComplete = false; + target.runtimeIdentity = runtimeIdentity; + target.latestGit = null; + target.latestForge = null; + target.latestSnapshot = null; + target.latestFacts = null; + target.factsPromise = null; + target.latestFingerprint = null; + } if (!request.force && target.latestSnapshot) { return target.latestSnapshot; } @@ -696,11 +953,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { async getCheckout(cwd: string): Promise { this.assertNotDisposed(); const normalizedCwd = resolve(cwd); - const status = await this.deps.getCheckoutStatus(normalizedCwd, { - paseoHome: this.paseoHome, - worktreesRoot: this.worktreesRoot, - logger: this.logger, - }); + const status = await this.withWorkspaceRuntime(normalizedCwd, () => + this.deps.getCheckoutStatus(normalizedCwd, { + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + logger: this.logger, + allowHostMetadata: this.selectedWorkspaceId === null, + }), + ); if (!status.isGit) { return checkoutLiteFromGitSnapshot(normalizedCwd, { isGit: false, @@ -711,13 +971,17 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { mainRepoRoot: null, }); } + const selected = this.selectedWorkspaceId !== null; + const repoRoot = selected ? normalizedCwd : status.repoRoot; + let mainRepoRoot = status.mainRepoRoot; + if (selected && mainRepoRoot !== null) mainRepoRoot = normalizedCwd; return checkoutLiteFromGitSnapshot(normalizedCwd, { isGit: true, currentBranch: status.currentBranch, remoteUrl: status.remoteUrl, - repoRoot: status.repoRoot, + repoRoot, isPaseoOwnedWorktree: status.isPaseoOwnedWorktree, - mainRepoRoot: status.mainRepoRoot, + mainRepoRoot, }); } @@ -726,7 +990,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null; } - getCheckoutDiff( + async getCheckoutDiff( cwd: string, options: CheckoutDiffCompare, readOptions?: WorkspaceGitReadOptions, @@ -734,15 +998,173 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.assertNotDisposed(); const normalizedCwd = resolve(cwd); const normalizedOptions = this.normalizeCheckoutDiffOptions(options); - const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions); + const key = this.buildCheckoutDiffCacheKey( + await this.runtimeCacheKey(normalizedCwd), + normalizedCwd, + normalizedOptions, + ); return this.readAuxiliaryCache(this.checkoutDiffCache, key, readOptions, () => - this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { + this.withWorkspaceRuntime(normalizedCwd, () => + this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + allowHostMetadata: this.selectedWorkspaceId === null, + }), + ), + ); + } + + async commit(cwd: string, options: { message: string; addAll: boolean }): Promise { + const normalizedCwd = resolve(cwd); + await this.withWorkspaceRuntime(normalizedCwd, () => commitChanges(normalizedCwd, options)); + await this.getSnapshot(normalizedCwd, { + force: true, + includeForge: false, + reason: "commit-changes", + }); + } + + async discardChanges(cwd: string, paths: string[]): Promise { + const normalizedCwd = resolve(cwd); + await this.withWorkspaceRuntime(normalizedCwd, () => discardChanges(normalizedCwd, paths)); + await this.getSnapshot(normalizedCwd, { + force: true, + includeForge: false, + reason: "discard-changes", + }); + } + + async createBranch(cwd: string, options: { branch: string; baseRef: string }): Promise { + const normalizedCwd = resolve(cwd); + await this.withWorkspaceRuntime(normalizedCwd, () => + runGitCommand(["checkout", "-b", options.branch, options.baseRef], { + cwd: normalizedCwd, + timeout: 120_000, + }).then(() => undefined), + ); + await this.getSnapshot(normalizedCwd, { + force: true, + includeForge: false, + reason: "create-branch", + }); + } + + async switchBranch(cwd: string, branch: string): Promise { + const normalizedCwd = resolve(cwd); + const resolution = await this.validateBranchRef(normalizedCwd, branch, { + force: true, + reason: "switch-branch", + }); + if (resolution.kind === "not-found") throw new Error(`Branch not found: ${branch}`); + const result = await this.withWorkspaceRuntime(normalizedCwd, () => + checkoutResolvedBranch({ cwd: normalizedCwd, resolution }), + ); + await this.getSnapshot(normalizedCwd, { + force: true, + includeForge: false, + reason: "switch-branch", + }); + return result; + } + + async fetch(cwd: string): Promise { + const normalizedCwd = resolve(cwd); + await this.withWorkspaceRuntime(normalizedCwd, () => + this.deps + .runGitFetch(normalizedCwd, { onRefSnapshot: () => undefined }) + .then(() => undefined), + ); + await this.getSnapshot(normalizedCwd, { + force: true, + includeForge: false, + reason: "fetch", + }); + } + + async listCommits(cwd: string): ReturnType { + const normalizedCwd = resolve(cwd); + return this.withWorkspaceRuntime(normalizedCwd, () => + listCheckoutCommits({ cwd: normalizedCwd }), + ); + } + + async getCommitFileDiff( + cwd: string, + input: { sha: string; path: string }, + ): ReturnType { + const normalizedCwd = resolve(cwd); + return this.withWorkspaceRuntime(normalizedCwd, () => + getCommitFileDiff({ + cwd: normalizedCwd, + sha: input.sha, + path: input.path, + allowFileRead: this.selectedWorkspaceId === null, + }), + ); + } + + async stashPush(cwd: string, message: string): Promise { + const normalizedCwd = resolve(cwd); + await this.withWorkspaceRuntime(normalizedCwd, () => + runGitCommand(["stash", "push", "--include-untracked", "-m", message], { + cwd: normalizedCwd, + timeout: 120_000, + }).then(() => undefined), + ); + } + + async stashPop(cwd: string, stashIndex: number): Promise { + const normalizedCwd = resolve(cwd); + await this.withWorkspaceRuntime(normalizedCwd, () => + runGitCommand(["stash", "pop", `stash@{${stashIndex}}`], { + cwd: normalizedCwd, + timeout: 120_000, + }).then(() => undefined), + ); + } + + async mergeToBase(cwd: string, options: Parameters[1]): Promise { + const normalizedCwd = resolve(cwd); + const selected = this.selectedWorkspaceId !== null; + const mutatedCwd = await this.withWorkspaceRuntime(normalizedCwd, () => + mergeToBase(normalizedCwd, options, { + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + }), + ); + return selected ? normalizedCwd : mutatedCwd; + } + + async mergeFromBase(cwd: string, options: Parameters[1]): Promise { + const normalizedCwd = resolve(cwd); + await this.withWorkspaceRuntime(normalizedCwd, () => + mergeFromBase(normalizedCwd, options, { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, }), ); } + async pull(cwd: string): Promise { + const normalizedCwd = resolve(cwd); + await this.withWorkspaceRuntime(normalizedCwd, () => pullCurrentBranch(normalizedCwd)); + } + + async push(cwd: string): Promise { + const normalizedCwd = resolve(cwd); + await this.withWorkspaceRuntime(normalizedCwd, () => pushCurrentBranch(normalizedCwd)); + } + + async renameBranch( + cwd: string, + branch: string, + ): Promise<{ previousBranch: string | null; currentBranch: string | null }> { + const normalizedCwd = resolve(cwd); + return this.withWorkspaceRuntime(normalizedCwd, () => + renameCurrentBranch(normalizedCwd, branch), + ); + } + private normalizeCheckoutDiffOptions(options: CheckoutDiffCompare): CheckoutDiffCompare { return { mode: options.mode, @@ -754,11 +1176,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }; } - private buildCheckoutDiffCacheKey(cwd: string, options: CheckoutDiffCompare): string { + private buildCheckoutDiffCacheKey( + cacheIdentity: string, + cwd: string, + options: CheckoutDiffCompare, + ): string { // Diff content varies by compare signature. Keep the cache per exact diff read shape so // hot diff panes coalesce while base refs and rendering options never share stale patches. return JSON.stringify([ "checkout-diff", + cacheIdentity, cwd, options.mode, options.mode === "base" ? (options.baseRef ?? null) : null, @@ -769,14 +1196,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private invalidateCheckoutDiffCache(cwd: string, mode: CheckoutDiffCompare["mode"]): void { for (const key of this.checkoutDiffCache.keys()) { - const [kind, cachedCwd, cachedMode] = JSON.parse(key) as unknown[]; + const [kind, , cachedCwd, cachedMode] = JSON.parse(key) as unknown[]; if (kind === "checkout-diff" && cachedCwd === cwd && cachedMode === mode) { this.checkoutDiffCache.delete(key); } } } - validateBranchRef( + async validateBranchRef( cwd: string, ref: string, options?: WorkspaceGitReadOptions, @@ -784,29 +1211,41 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.assertNotDisposed(); const normalizedCwd = resolve(cwd); const normalizedRef = ref.trim(); - const key = JSON.stringify(["branch-validation", normalizedCwd, normalizedRef]); + const key = JSON.stringify([ + "branch-validation", + await this.runtimeCacheKey(normalizedCwd), + normalizedRef, + ]); return this.readAuxiliaryCache(this.branchValidationCache, key, options, () => - this.deps.resolveBranchCheckout(normalizedCwd, normalizedRef), + this.withWorkspaceRuntime(normalizedCwd, () => + this.deps.resolveBranchCheckout(normalizedCwd, normalizedRef), + ), ); } - hasLocalBranch(cwd: string, branch: string, options?: WorkspaceGitReadOptions): Promise { + async hasLocalBranch( + cwd: string, + branch: string, + options?: WorkspaceGitReadOptions, + ): Promise { this.assertNotDisposed(); const normalizedCwd = resolve(cwd); const normalizedBranch = branch.trim(); const ref = `refs/heads/${normalizedBranch}`; - const key = JSON.stringify(["local-branch", normalizedCwd, ref]); - return this.readAuxiliaryCache(this.localBranchCache, key, options, async () => { - const result = await this.deps.runGitCommand(["rev-parse", "--verify", "--quiet", ref], { - cwd: normalizedCwd, - envOverlay: READ_ONLY_GIT_ENV, - acceptExitCodes: [0, 1], - }); - return result.exitCode === 0; - }); + const key = JSON.stringify(["local-branch", await this.runtimeCacheKey(normalizedCwd), ref]); + return this.readAuxiliaryCache(this.localBranchCache, key, options, () => + this.withWorkspaceRuntime(normalizedCwd, async () => { + const result = await this.deps.runGitCommand(["rev-parse", "--verify", "--quiet", ref], { + cwd: normalizedCwd, + envOverlay: READ_ONLY_GIT_ENV, + acceptExitCodes: [0, 1], + }); + return result.exitCode === 0; + }), + ); } - suggestBranchesForCwd( + async suggestBranchesForCwd( cwd: string, options?: WorkspaceGitBranchSuggestionsOptions, readOptions?: WorkspaceGitReadOptions, @@ -815,13 +1254,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const normalizedCwd = resolve(cwd); const query = options?.query ?? ""; const limit = options?.limit; - const key = JSON.stringify(["branch-suggestions", normalizedCwd, query, limit ?? null]); + const key = JSON.stringify([ + "branch-suggestions", + await this.runtimeCacheKey(normalizedCwd), + query, + limit ?? null, + ]); return this.readAuxiliaryCache(this.branchSuggestionsCache, key, readOptions, () => - this.deps.listBranchSuggestions(normalizedCwd, options), + this.withWorkspaceRuntime(normalizedCwd, () => + this.deps.listBranchSuggestions(normalizedCwd, options), + ), ); } - listStashes( + async listStashes( cwd: string, options?: WorkspaceGitStashListOptions, readOptions?: WorkspaceGitReadOptions, @@ -829,14 +1275,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.assertNotDisposed(); const normalizedCwd = resolve(cwd); const paseoOnly = options?.paseoOnly !== false; - const key = JSON.stringify(["stashes", normalizedCwd, paseoOnly]); - return this.readAuxiliaryCache(this.stashListCache, key, readOptions, async () => { - const { stdout } = await this.deps.runGitCommand(["stash", "list", "--format=%gd%x00%s"], { - cwd: normalizedCwd, - envOverlay: READ_ONLY_GIT_ENV, - }); - return parseWorkspaceGitStashList(stdout, { paseoOnly }); - }); + const key = JSON.stringify(["stashes", await this.runtimeCacheKey(normalizedCwd), paseoOnly]); + return this.readAuxiliaryCache(this.stashListCache, key, readOptions, () => + this.withWorkspaceRuntime(normalizedCwd, async () => { + const { stdout } = await this.deps.runGitCommand(["stash", "list", "--format=%gd%x00%s"], { + cwd: normalizedCwd, + envOverlay: READ_ONLY_GIT_ENV, + }); + return parseWorkspaceGitStashList(stdout, { paseoOnly }); + }), + ); } async listWorktrees( @@ -845,13 +1293,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ): Promise { this.assertNotDisposed(); const repoRoot = await this.resolveRepoRoot(cwdOrRepoRoot, options); - const key = JSON.stringify(["worktrees", repoRoot]); + const key = JSON.stringify(["worktrees", await this.runtimeCacheKey(cwdOrRepoRoot, repoRoot)]); return this.readAuxiliaryCache(this.worktreeListCache, key, options, () => - this.deps.listPaseoWorktrees({ - cwd: repoRoot, - paseoHome: this.paseoHome, - worktreesRoot: this.worktreesRoot, - }), + this.withWorkspaceRuntime(cwdOrRepoRoot, () => + this.deps.listPaseoWorktrees({ + cwd: repoRoot, + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + }), + ), ); } @@ -872,14 +1322,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ): Promise { this.assertNotDisposed(); const cwd = resolve(cwdOrRepoRoot); - const key = JSON.stringify(["default-branch", cwd]); - return this.readAuxiliaryCache(this.defaultBranchCache, key, options, async () => { - const defaultBranch = await this.deps.resolveRepositoryDefaultBranch(cwd); - if (!defaultBranch) { - throw new Error("Unable to resolve repository default branch"); - } - return defaultBranch; - }); + const key = JSON.stringify(["default-branch", await this.runtimeCacheKey(cwd)]); + return this.readAuxiliaryCache(this.defaultBranchCache, key, options, () => + this.withWorkspaceRuntime(cwd, async () => { + const defaultBranch = await this.deps.resolveRepositoryDefaultBranch(cwd); + if (!defaultBranch) throw new Error("Unable to resolve repository default branch"); + return defaultBranch; + }), + ); } async getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise { @@ -969,26 +1419,40 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.disposeController.abort(new WorkspaceGitServiceDisposedError()); this.workspaceRefreshLimit.clearQueue(); this.workspaceObservationSetupLimit.clearQueue(); + const selectedWorkspaceDisposals = [...this.selectedWorkspaces.values()].map(({ service }) => + service.dispose(), + ); + this.selectedWorkspaces.clear(); + this.legacyWorkspaces.clear(); - for (const target of this.workspaceTargets.values()) { - this.closeWorkspaceTarget(target); - } - this.workspaceTargets.clear(); - - for (const target of this.repoTargets.values()) { - this.closeRepoTarget(target); - } + const repoTargetDisposals = [...this.repoTargets.values()].map((target) => + this.closeRepoTarget(target), + ); this.repoTargets.clear(); - for (const target of this.workingTreeWatchTargets.values()) { - this.closeWorkingTreeWatchTarget(target); - } + const workingTreeWatchDisposals = [...this.workingTreeWatchTargets.values()].map((target) => + this.closeWorkingTreeWatchTarget(target), + ); this.workingTreeWatchTargets.clear(); + + const workspaceTargetDisposals = [...this.workspaceTargets.values()].map((target) => + this.closeWorkspaceTarget(target), + ); + this.workspaceTargets.clear(); + this.workingTreeWatchSetups.clear(); this.workingTreeWatchResolutions.clear(); this.workingTreeWatchAliases.clear(); - this.snapshotUpdatedListeners.clear(); - this.disposePromise = this.fileObserver.close(); + if (this.ownsSnapshotUpdatedListeners) { + this.snapshotUpdatedListeners.clear(); + } + this.disposePromise = Promise.all([ + this.fileObserver.close(), + ...selectedWorkspaceDisposals, + ...repoTargetDisposals, + ...workingTreeWatchDisposals, + ...workspaceTargetDisposals, + ]).then(() => undefined); return this.disposePromise; } @@ -1131,6 +1595,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private createWorkspaceTarget(cwd: string): WorkspaceGitTarget { const target: WorkspaceGitTarget = { cwd, + runtimeIdentity: null, + runtimeObservation: null, listeners: new Set(), workingTreeWatchTarget: null, debounceTimer: null, @@ -1168,7 +1634,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { force: false, refreshStructure: true, refreshWorktree: true, - includeForge: true, + includeForge: this.selectedWorkspaceId === null, reason: "initial", notify: true, queueIfBusy: false, @@ -1207,6 +1673,18 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } private async setupWorkspaceObservation(target: WorkspaceGitTarget): Promise { + const selected = await this.resolveBoundRuntime(); + if (selected) { + target.runtimeObservation = await observeWorkspaceGit(selected.runtime, () => { + if (!this.isActiveObservedWorkspaceTarget(target)) return; + this.scheduleWorkspaceRefresh(target, { + force: true, + reason: "runtime-git-observation", + }); + }); + target.observationSetupComplete = true; + return; + } const facts = await this.getFactsForObservation(target); if (!this.isActiveObservedWorkspaceTarget(target)) { return; @@ -1250,6 +1728,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return this.loadCheckoutFacts(target, { paseoHome: this.paseoHome, logger: this.logger, + allowHostMetadata: this.selectedWorkspaceId === null, }); } @@ -2682,7 +3161,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { force, refreshStructure: true, refreshWorktree: true, - includeForge: options?.includeForge ?? true, + includeForge: options?.includeForge ?? this.selectedWorkspaceId === null, reason: options?.reason ?? defaultReason, notify, queueIfBusy: false, @@ -2782,7 +3261,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (target.closed || this.workspaceTargets.get(target.cwd) !== target) { return null; } - return this.refreshSnapshot(target, request); + return this.withWorkspaceRuntime(target.cwd, () => this.refreshSnapshot(target, request)); }); if (!admittedSnapshot) { break; @@ -2859,6 +3338,42 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return snapshot; } + private async withWorkspaceRuntime(_cwd: string, operation: () => Promise): Promise { + const resolved = await this.resolveBoundRuntime(); + if (!resolved) return operation(); + return runWithGitCommandRunner( + (args, options) => runRuntimeGitCommand(resolved.runtime, args, options), + operation, + ); + } + + private async resolveBoundRuntime(): Promise<{ + workspaceId: string; + runtime: BoundWorkspaceRuntime; + } | null> { + if (!this.workspaceRuntime || !this.selectedWorkspaceId) return null; + return { + workspaceId: this.selectedWorkspaceId, + runtime: await this.workspaceRuntime.bind(this.selectedWorkspaceId), + }; + } + + private async runtimeCacheKey(cwd: string, legacyIdentity = cwd): Promise { + const resolved = await this.resolveBoundRuntime(); + if (!resolved) return `legacy:${resolve(legacyIdentity)}`; + return `workspace:${resolved.workspaceId}:${this.getRuntimeCacheToken(resolved.runtime)}`; + } + + private getRuntimeCacheToken(runtime: BoundWorkspaceRuntime): number { + let token = this.runtimeCacheTokens.get(runtime); + if (token === undefined) { + token = this.nextRuntimeCacheToken; + this.nextRuntimeCacheToken += 1; + this.runtimeCacheTokens.set(runtime, token); + } + return token; + } + private async refreshRefDerivedSnapshot( target: WorkspaceGitTarget, facts: Extract, @@ -2928,6 +3443,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, logger: this.logger, + allowHostMetadata: this.selectedWorkspaceId === null, + cacheIdentity: + this.selectedWorkspaceId && target.runtimeIdentity + ? `workspace:${this.selectedWorkspaceId}:${this.getRuntimeCacheToken(target.runtimeIdentity)}` + : `legacy:${resolve(target.cwd)}`, }; const facts = await this.loadCheckoutFacts(target, baseContext); const context: CheckoutContext = { ...baseContext, facts }; @@ -3020,11 +3540,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return target.latestSnapshot ?? buildNotGitSnapshot(target.cwd); } - return { + const snapshot = { cwd: target.cwd, git: target.latestGit, forge: target.latestForge ?? buildForgeUnavailableSnapshot(), }; + if (!target.runtimeIdentity || !snapshot.git.isGit) return snapshot; + return { + ...snapshot, + git: { + ...snapshot.git, + repoRoot: target.cwd, + mainRepoRoot: snapshot.git.mainRepoRoot === null ? null : target.cwd, + }, + }; } private getForgePrStatusPollKey(target: WorkspaceGitTarget): string | null { @@ -3218,14 +3747,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const repoTarget = this.repoTargets.get(target.repoGitRoot); repoTarget?.workspaceKeys.delete(target.cwd); if (repoTarget && repoTarget.workspaceKeys.size === 0) { - this.closeRepoTarget(repoTarget); + void this.closeRepoTarget(repoTarget); this.repoTargets.delete(target.repoGitRoot); } else if (repoTarget?.cwd === target.cwd) { repoTarget.cwd = repoTarget.workspaceKeys.values().next().value ?? repoTarget.cwd; } } - this.closeWorkspaceTarget(target); + void this.closeWorkspaceTarget(target); this.workspaceTargets.delete(target.cwd); } @@ -3240,7 +3769,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } - this.closeWorkingTreeWatchTarget(target); + void this.closeWorkingTreeWatchTarget(target); this.workingTreeWatchTargets.delete(cwd); } @@ -3252,7 +3781,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (target.workspaceKeys.size > 0 || target.listeners.size > 0) { return; } - this.closeWorkingTreeWatchTarget(target); + void this.closeWorkingTreeWatchTarget(target); if (this.workingTreeWatchTargets.get(target.cwd) === target) { this.workingTreeWatchTargets.delete(target.cwd); } @@ -3267,12 +3796,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ) { return; } - this.closeWorkingTreeWatchTarget(target); + void this.closeWorkingTreeWatchTarget(target); this.workingTreeWatchTargets.delete(target.cwd); } - private closeWorkspaceTarget(target: WorkspaceGitTarget): void { + private closeWorkspaceTarget(target: WorkspaceGitTarget): Promise { target.closed = true; + const runtimeObservationDisposal = + target.runtimeObservation?.unsubscribe() ?? Promise.resolve(); + target.runtimeObservation = null; if (target.workingTreeWatchTarget) { this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd); target.workingTreeWatchTarget = null; @@ -3287,9 +3819,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } this.stopForgePrStatusPollForTarget(target); target.listeners.clear(); + return runtimeObservationDisposal; } - private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void { + private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): Promise { target.closed = true; for (const alias of target.aliases) { if (this.workingTreeWatchAliases.get(alias) === target.cwd) { @@ -3307,18 +3840,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.recovery.timer = null; } + let subscriptionDisposal = Promise.resolve(); if (target.subscription) { const subscription = target.subscription; target.subscription = null; - void subscription.unsubscribe().catch((error) => { + subscriptionDisposal = subscription.unsubscribe().catch((error) => { this.logger.warn({ err: error, cwd: target.cwd }, "Failed to stop working tree watcher"); }); } target.workspaceKeys.clear(); target.listeners.clear(); + return subscriptionDisposal; } - private closeRepoTarget(target: RepoGitTarget): void { + private closeRepoTarget(target: RepoGitTarget): Promise { target.closed = true; if (target.intervalId) { clearInterval(target.intervalId); @@ -3333,10 +3868,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { clearTimeout(target.recovery.timer); target.recovery.timer = null; } + let subscriptionDisposal = Promise.resolve(); if (target.subscription) { const subscription = target.subscription; target.subscription = null; - void subscription.unsubscribe().catch((error) => { + subscriptionDisposal = subscription.unsubscribe().catch((error) => { this.logger.warn( { err: error, repoGitRoot: target.repoGitRoot }, "Failed to stop repository metadata watcher", @@ -3344,7 +3880,110 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }); } target.workspaceKeys.clear(); + return subscriptionDisposal; + } +} + +const runtimeGitExecutables = new WeakMap>(); + +function resolveRuntimeGitExecutable(runtime: BoundWorkspaceRuntime): Promise { + const existing = runtimeGitExecutables.get(runtime); + if (existing) return existing; + const resolving = runtime.resolveCommand("git").then((executable) => { + if (!executable) throw new Error("Git is not available in the workspace runtime"); + return executable; + }); + runtimeGitExecutables.set(runtime, resolving); + void resolving.catch(() => runtimeGitExecutables.delete(runtime)); + return resolving; +} + +async function runRuntimeGitCommand( + runtime: BoundWorkspaceRuntime, + args: string[], + options: GitCommandOptions, +): Promise { + const gitExecutable = await resolveRuntimeGitExecutable(runtime); + const process = await runtime.run({ + argv: [gitExecutable, "-c", "core.quotepath=false", ...args], + env: buildRuntimeGitEnvironment(options), + purpose: { kind: "git" }, + }); + process.stdin.end(); + const timeout = options.timeout ?? 30_000; + const maxOutputBytes = options.maxOutputBytes ?? 20 * 1024 * 1024; + let timer: ReturnType | undefined; + const timedOut = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + process.kill("SIGKILL"); + reject(new Error(`Git command timed out after ${timeout}ms: git ${args.join(" ")}`)); + }, timeout); + }); + try { + const completed = Promise.all([ + collectRuntimeGitOutput(process.stdout, maxOutputBytes, () => process.kill("SIGKILL")), + collectRuntimeGitOutput(process.stderr, 2_048, () => undefined), + process.exited, + ]).then(([stdout, stderr, exit]) => ({ stdout, stderr, exit })); + const { stdout, stderr, exit } = await Promise.race([completed, timedOut]); + const result: GitCommandResult = { + stdout: stdout.contents, + stderr: stderr.contents, + truncated: stdout.truncated, + exitCode: exit.code, + signal: exit.signal, + }; + const accepted = options.acceptExitCodes ?? [0]; + if (!result.truncated && !accepted.includes(result.exitCode ?? -1)) { + const stderrPreview = result.stderr.trim() || "(no stderr)"; + throw new Error( + `Git command failed: git ${args.join(" ")} (exit code: ${String(result.exitCode)}, signal: ${result.signal ?? "none"})\n${stderrPreview}`, + ); + } + return result; + } finally { + if (timer) clearTimeout(timer); + } +} + +function buildRuntimeGitEnvironment(options: GitCommandOptions): Readonly> { + const environment: Record = {}; + for (const name of ["HOME", "LANG", "LC_ALL", "TMPDIR", "XDG_CONFIG_HOME"] as const) { + const value = process.env[name]; + if (value !== undefined) environment[name] = value; + } + for (const [name, value] of Object.entries(options.env ?? {})) { + if (value !== undefined) environment[name] = value; + } + for (const [name, value] of Object.entries(options.envOverlay ?? {})) { + if (value !== undefined) environment[name] = value; + } + environment.PATH = process.env.PATH ?? ""; + return environment; +} + +async function collectRuntimeGitOutput( + stream: NodeJS.ReadableStream, + limit: number, + onLimit: () => void, +): Promise<{ contents: string; truncated: boolean }> { + const chunks: Buffer[] = []; + let size = 0; + let truncated = false; + for await (const chunk of stream) { + if (truncated) continue; + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const remaining = limit - size; + if (bytes.length > remaining) { + if (remaining > 0) chunks.push(bytes.subarray(0, remaining)); + truncated = true; + onLimit(); + continue; + } + chunks.push(bytes); + size += bytes.length; } + return { contents: Buffer.concat(chunks).toString("utf8"), truncated }; } async function loadForgeSnapshot(options: { diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 1a885378db..6229511c31 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -367,6 +367,51 @@ describe("WorkspaceReconciliationService", () => { expect(workspaces.get("w1")?.archivedAt).toEqual(expect.any(String)); }); + test("never treats a selected runtime compatibility cwd as a host directory", async () => { + const projectRoot = realpathSync( + mkdtempSync(path.join(tmpdir(), "reconcile-runtime-project-")), + ); + tempDirs.push(projectRoot); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + projects.set( + "runtime-project", + createPersistedProjectRecord({ + projectId: "runtime-project", + rootPath: projectRoot, + kind: "git", + displayName: "runtime-project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "docker-workspace", + createPersistedWorkspaceRecord({ + workspaceId: "docker-workspace", + projectId: "runtime-project", + cwd: "/workspace", + kind: "local_checkout", + displayName: "docker-workspace", + runtime: { runtimeId: "docker" }, + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + }); + + await service.reconcileGitMetadata(); + const result = await service.runOnce(); + + expect(result.changesApplied).not.toContainEqual( + expect.objectContaining({ kind: "workspace_archived", workspaceId: "docker-workspace" }), + ); + expect(workspaces.get("docker-workspace")?.archivedAt).toBeNull(); + }); + test("reads fresh checkout facts on every metadata pass", async () => { const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-fresh-git-"))); tempDirs.push(projectRoot); @@ -693,6 +738,7 @@ describe("WorkspaceReconciliationService", () => { workspaceId: "w1", projectId: "p1", cwd: "/tmp/does-not-exist-reconcile-orphan", + hostVisiblePath: null, kind: "directory", displayName: "orphan", title: null, @@ -706,6 +752,7 @@ describe("WorkspaceReconciliationService", () => { updatedAt: expect.any(String), archivedAt: expect.any(String), autoArchivedChangeRequestUrl: null, + deletionRequestedAt: null, }); expect(projects.get("p1")).toEqual(project); }); diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index 59d2f9c124..ef1c918181 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -198,7 +198,12 @@ export class WorkspaceReconciliationService { ]); const workspacesByProject = new Map(); for (const workspace of workspaces) { - if (workspace.archivedAt || this.inspectDirectory(workspace.cwd) !== "directory") continue; + if ( + workspace.archivedAt || + workspace.runtime || + this.inspectDirectory(workspace.cwd) !== "directory" + ) + continue; const siblings = workspacesByProject.get(workspace.projectId) ?? []; siblings.push(workspace); workspacesByProject.set(workspace.projectId, siblings); @@ -222,7 +227,9 @@ export class WorkspaceReconciliationService { const allWorkspaces = await this.workspaceRegistry.list(); const activeProjects = allProjects.filter((p) => !p.archivedAt); - const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt); + // Runtime-selected workspace placement is recovered by driver inspect. Its + // compatibility cwd may be runtime-local and must never be statted on the host. + const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt && !w.runtime); const workspaceDirectoryStates = activeWorkspaces.map((workspace) => ({ workspace, state: this.inspectDirectory(workspace.cwd), diff --git a/packages/server/src/server/workspace-registry-model.test.ts b/packages/server/src/server/workspace-registry-model.test.ts index 1f51a0c4a1..77f2fb939c 100644 --- a/packages/server/src/server/workspace-registry-model.test.ts +++ b/packages/server/src/server/workspace-registry-model.test.ts @@ -9,7 +9,61 @@ import { initialWorkspacePlacement, reconcileWorkspacePlacement, } from "./workspace-registry-model.js"; -import { createPersistedWorkspaceRecord } from "./workspace-registry.js"; +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, + projectRuntimeSource, +} from "./workspace-registry.js"; + +describe("project runtime source", () => { + const project = createPersistedProjectRecord({ + projectId: "project-one", + rootPath: "/host/decoy", + kind: "git", + displayName: "project-one", + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }); + + test("uses the host project root only when no persisted source exists", () => { + expect(projectRuntimeSource(project)).toEqual({ + kind: "host-directory", + path: "/host/decoy", + }); + }); + + test("gives persisted git authority precedence over the host root", () => { + expect( + projectRuntimeSource({ + ...project, + source: { + kind: "git", + url: "https://example.test/acme/project.git", + revision: "release", + subdirectory: "packages/app", + }, + }), + ).toEqual({ + kind: "git", + url: "https://example.test/acme/project.git", + revision: "release", + subdirectory: "packages/app", + }); + }); + + test("uses the runtime contract default revision when persistence omits it", () => { + expect( + projectRuntimeSource({ + ...project, + source: { kind: "git", url: "https://example.test/acme/project.git" }, + }), + ).toEqual({ + kind: "git", + url: "https://example.test/acme/project.git", + revision: "", + }); + }); +}); describe("opaque registry ids", () => { test("generates opaque project ids", () => { diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index acc4d49538..88c9facc90 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -69,6 +69,13 @@ export type InitialWorkspacePlacementInput = branch: string | null; baseBranch: string | null; mainRepoRoot: string; + } + | { + source: "runtime_worktree"; + cwd: string; + branch: string | null; + baseBranch: string | null; + mainRepoRoot: string; }; export interface WorkspacePlacementUpdate { @@ -80,6 +87,18 @@ export interface WorkspacePlacementUpdate { export function initialWorkspacePlacement( input: InitialWorkspacePlacementInput, ): PersistedWorkspacePlacement { + if (input.source === "runtime_worktree") { + return { + cwd: input.cwd, + kind: "worktree", + displayName: input.branch || input.cwd, + branch: input.branch, + worktreeRoot: null, + baseBranch: input.baseBranch, + isPaseoOwnedWorktree: true, + mainRepoRoot: input.mainRepoRoot, + }; + } if (input.source === "created_worktree") { return { cwd: input.cwd, diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 75d0097b0a..8aea9ee167 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -1,6 +1,6 @@ import os from "node:os"; import path from "node:path"; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { beforeEach, afterEach, describe, expect, test } from "vitest"; @@ -12,6 +12,7 @@ import { FileBackedWorkspaceRegistry, resolveWorkspaceDisplayName, resolveWorkspaceName, + resolveSelectedWorkspaceRuntimeId, } from "./workspace-registry.js"; describe("resolveWorkspaceName", () => { @@ -43,6 +44,71 @@ describe("resolveWorkspaceName", () => { }); }); +describe("workspace runtime selection compatibility", () => { + test.each(["local_checkout", "directory", "worktree"] as const)( + "keeps an existing %s record on its legacy path until explicit cutover", + (kind) => { + const existing = createPersistedWorkspaceRecord({ + workspaceId: `legacy-${kind}`, + projectId: "legacy-project", + cwd: "/tmp/legacy", + kind, + displayName: "legacy", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + expect(resolveSelectedWorkspaceRuntimeId(existing)).toBeNull(); + expect( + resolveSelectedWorkspaceRuntimeId({ ...existing, runtime: { runtimeId: "selected" } }), + ).toBe("selected"); + }, + ); +}); + +describe("persisted project source validation", () => { + const input = { + projectId: "project-source", + rootPath: "/host/project", + kind: "git" as const, + displayName: "project", + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }; + + test("accepts optional git revision and subdirectory", () => { + expect( + createPersistedProjectRecord({ + ...input, + source: { + kind: "git", + url: "https://example.test/acme/project.git", + revision: "main", + subdirectory: "packages/server", + }, + }), + ).toMatchObject({ source: { kind: "git", revision: "main", subdirectory: "packages/server" } }); + }); + + test("rejects a Git source on a non-Git project", () => { + expect(() => + createPersistedProjectRecord({ + ...input, + kind: "non_git", + source: { kind: "git", url: "https://example.test/acme/project.git" }, + }), + ).toThrow("A persisted Git source requires project kind git"); + }); + + test.each([ + { kind: "git", url: "" }, + { kind: "git", url: "https://example.test/acme/project.git", revision: "" }, + { kind: "git", url: "https://example.test/acme/project.git", subdirectory: "" }, + { kind: "git", url: "https://example.test/acme/project.git", privateRoot: "/escape" }, + ])("rejects malformed source at the persistence edge: %j", (source) => { + expect(() => createPersistedProjectRecord({ ...input, source })).toThrow(); + }); +}); + describe("workspace registries", () => { let tmpDir: string; let projectRegistry: FileBackedProjectRegistry; @@ -65,6 +131,32 @@ describe("workspace registries", () => { rmSync(tmpDir, { recursive: true, force: true }); }); + test("rejects a contradictory project source while loading persisted records", async () => { + mkdirSync(path.join(tmpDir, "projects"), { recursive: true }); + writeFileSync( + path.join(tmpDir, "projects", "projects.json"), + JSON.stringify([ + { + projectId: "contradictory-project", + rootPath: "/host/project", + kind: "non_git", + displayName: "project", + source: { kind: "git", url: "https://example.test/acme/project.git" }, + projectKey: null, + customName: null, + customIconRevision: null, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + archivedAt: null, + }, + ]), + ); + + await projectRegistry.initialize(); + + expect(await projectRegistry.list()).toEqual([]); + }); + test("creates, updates, archives, deletes, and lists project records", async () => { await projectRegistry.initialize(); await projectRegistry.upsert( @@ -184,6 +276,31 @@ describe("workspace registries", () => { expect(opaque.projectId).toMatch(/^prj_[0-9a-f]{16}$/); }); + test("does not downgrade a sourced Git project when its convenience root is non-Git", async () => { + await projectRegistry.initialize(); + const rootPath = path.join(tmpDir, "decoy-root"); + const project = createPersistedProjectRecord({ + projectId: "prj_sourced_git", + rootPath, + kind: "git", + source: { kind: "git", url: "https://example.com/source.git" }, + displayName: "source", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + await projectRegistry.upsert(project); + + const resolved = await projectRegistry.getOrCreateActiveByRoot({ + rootPath, + kind: "non_git", + displayName: "decoy-root", + timestamp: "2026-03-02T00:00:00.000Z", + }); + + expect(resolved.kind).toBe("git"); + expect(resolved.source).toEqual(project.source); + }); + test("allocates a fresh opaque ID when only an archived exact root exists", async () => { await projectRegistry.initialize(); const rootPath = path.join(tmpDir, "archived-root"); @@ -397,6 +514,136 @@ 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, + materializingAt: reserved.updatedAt, + }); + 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 a runtime reservation hidden after restart until final placement is published", async () => { + await workspaceRegistry.initialize(); + const reserved = createPersistedWorkspaceRecord({ + workspaceId: "interrupted-runtime-workspace", + projectId: "runtime-project", + cwd: "/tmp/source-must-stay-private", + kind: "worktree", + displayName: "interrupted runtime", + runtime: { runtimeId: "docker" }, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + + await workspaceRegistry.upsert(reserved, { provisional: true }); + + const restarted = new FileBackedWorkspaceRegistry( + path.join(tmpDir, "projects", "workspaces.json"), + logger, + ); + await restarted.initialize(); + + expect(await restarted.get(reserved.workspaceId)).toMatchObject({ + workspaceId: reserved.workspaceId, + cwd: "/tmp/source-must-stay-private", + }); + expect(await restarted.list()).toEqual([]); + }); + + 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`; + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "durable-workspace", + projectId: "durable-project", + cwd: "/tmp/durable", + kind: "local_checkout", + displayName: "durable", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + await workspaceRegistry.upsert(workspace); + renameSync(registryPath, backupPath); + mkdirSync(registryPath); + + await expect(workspaceRegistry.remove(workspace.workspaceId)).rejects.toThrow(); + expect(await workspaceRegistry.get(workspace.workspaceId)).toEqual(workspace); + + rmSync(registryPath, { recursive: true }); + renameSync(backupPath, registryPath); + const reconstructed = new FileBackedWorkspaceRegistry(registryPath, logger); + await expect(reconstructed.get(workspace.workspaceId)).resolves.toEqual(workspace); + await expect(workspaceRegistry.remove(workspace.workspaceId)).resolves.toBeUndefined(); + await expect(workspaceRegistry.get(workspace.workspaceId)).resolves.toBeNull(); + }); + + test("does not expose deletion intent until it is durably persisted", async () => { + const registryPath = path.join(tmpDir, "projects", "workspaces.json"); + const backupPath = `${registryPath}.backup`; + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "intent-workspace", + projectId: "intent-project", + cwd: "/tmp/intent", + kind: "local_checkout", + displayName: "intent", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + await workspaceRegistry.upsert(workspace); + renameSync(registryPath, backupPath); + mkdirSync(registryPath); + + await expect( + workspaceRegistry.requestDeletion(workspace.workspaceId, "2026-03-02T00:00:00.000Z"), + ).rejects.toThrow(); + expect(await workspaceRegistry.get(workspace.workspaceId)).toEqual(workspace); + + rmSync(registryPath, { recursive: true }); + renameSync(backupPath, registryPath); + const reconstructedBeforeRetry = new FileBackedWorkspaceRegistry(registryPath, logger); + await expect(reconstructedBeforeRetry.get(workspace.workspaceId)).resolves.toEqual(workspace); + + await workspaceRegistry.requestDeletion(workspace.workspaceId, "2026-03-02T00:00:00.000Z"); + const reconstructedAfterRetry = new FileBackedWorkspaceRegistry(registryPath, logger); + await expect(reconstructedAfterRetry.get(workspace.workspaceId)).resolves.toMatchObject({ + deletionRequestedAt: "2026-03-02T00:00:00.000Z", + }); + }); + test("refreshes workspace archive timestamps when an archive is repeated", async () => { await workspaceRegistry.initialize(); await workspaceRegistry.upsert( diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 5233e77e85..0b2470296c 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -2,6 +2,7 @@ import { promises as fs } from "node:fs"; import type { Logger } from "pino"; import { z } from "zod"; +import type { WorkspaceProjectSource } from "./workspace-runtime/index.js"; import { writeJsonFileAtomic } from "./atomic-file.js"; import { areEquivalentPaths } from "../utils/path.js"; @@ -11,39 +12,63 @@ import { type PersistedWorkspaceKind, } from "./workspace-registry-model.js"; -const PersistedProjectRecordSchema = z.object({ - projectId: z.string(), - rootPath: z.string(), - kind: z.enum(["git", "non_git"]), - displayName: z.string(), - // COMPAT(projectKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28. - projectKey: z - .string() - .nullable() - .optional() - .transform((value) => value ?? null), - // User-set override layered over the derived displayName. Reconciliation - // never touches this. Null means "use the derived name". Added for #987. - customName: z - .string() - .nullable() - .optional() - .transform((value) => value ?? null), - // Identifies the project's stored custom icon; null means automatic. - customIconRevision: z - .string() - .nullable() - .optional() - .transform((value) => value ?? null), - createdAt: z.string(), - updatedAt: z.string(), - archivedAt: z.string().nullable(), -}); +const PersistedProjectRecordSchema = z + .object({ + projectId: z.string(), + rootPath: z.string(), + kind: z.enum(["git", "non_git"]), + displayName: z.string(), + source: z + .object({ + kind: z.literal("git"), + url: z.string().min(1), + revision: z.string().min(1).optional(), + subdirectory: z.string().min(1).optional(), + }) + .strict() + .optional(), + // COMPAT(projectKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28. + projectKey: z + .string() + .nullable() + .optional() + .transform((value) => value ?? null), + // User-set override layered over the derived displayName. Reconciliation + // never touches this. Null means "use the derived name". Added for #987. + customName: z + .string() + .nullable() + .optional() + .transform((value) => value ?? null), + // Identifies the project's stored custom icon; null means automatic. + customIconRevision: z + .string() + .nullable() + .optional() + .transform((value) => value ?? null), + createdAt: z.string(), + updatedAt: z.string(), + archivedAt: z.string().nullable(), + }) + .superRefine((project, context) => { + if (project.source && project.kind !== "git") { + context.addIssue({ + code: "custom", + path: ["source"], + message: "A persisted Git source requires project kind git", + }); + } + }); const PersistedWorkspaceRecordSchema = z.object({ workspaceId: z.string(), projectId: z.string(), cwd: z.string(), + hostVisiblePath: z + .string() + .nullable() + .optional() + .transform((value) => value ?? null), kind: z.enum(["local_checkout", "worktree", "directory"]), displayName: z.string(), // User-set title layered over the derived displayName. In Model B the title is @@ -77,6 +102,14 @@ const PersistedWorkspaceRecordSchema = z.object({ .transform((value) => value ?? null), isPaseoOwnedWorktree: z.boolean().default(false), mainRepoRoot: z.string().nullable().default(null), + runtime: z.object({ runtimeId: z.string().min(1) }).optional(), + materializingAt: z.string().optional(), + // COMPAT(workspaceDeletionIntent): added in v0.2.7 on 2026-08-11; remove optional after 2027-02-11. + deletionRequestedAt: z + .string() + .nullable() + .optional() + .transform((value) => value ?? null), createdAt: z.string(), updatedAt: z.string(), archivedAt: z.string().nullable(), @@ -97,15 +130,23 @@ const PersistedWorkspaceRecordSchema = z.object({ export type PersistedProjectRecord = z.infer; export type PersistedWorkspaceRecord = z.infer; +export function resolveSelectedWorkspaceRuntimeId( + workspace: PersistedWorkspaceRecord, +): string | null { + return workspace.runtime?.runtimeId ?? null; +} + export interface WorkspaceMutation { kind: "upsert" | "archive" | "remove"; workspaceId: string; workspace: PersistedWorkspaceRecord | null; expectsInitialAgent?: boolean; + provisional?: boolean; } export interface WorkspaceMutationContext { expectsInitialAgent?: boolean; + provisional?: boolean; } export interface WorkspaceArchiveContext { @@ -157,6 +198,7 @@ export interface WorkspaceRegistry { context?: WorkspaceArchiveContext, ): Promise; remove(workspaceId: string): Promise; + requestDeletion(workspaceId: string, requestedAt: string): Promise; /** Central lifecycle seam for daemon-global workspace observers. */ subscribeToMutations?( listener: (mutation: WorkspaceMutation) => void | Promise, @@ -172,7 +214,7 @@ class FileBackedRegistry { private readonly getId: (record: TRecord) => string; private loaded = false; private readonly cache = new Map(); - private persistQueue: Promise = Promise.resolve(); + private mutationQueue: Promise = Promise.resolve(); constructor(options: { filePath: string; @@ -214,22 +256,20 @@ class FileBackedRegistry { } async upsert(record: TRecord): Promise { - await this.load(); const parsed = this.schema.parse(record); - this.cache.set(this.getId(parsed), parsed); - await this.enqueuePersist(); + await this.mutate((records) => { + records.set(this.getId(parsed), parsed); + }); } async update(id: string, updater: (record: TRecord) => TRecord): Promise { - await this.load(); - const existing = this.cache.get(id); - if (!existing) { - return null; - } - const next = this.schema.parse(updater(existing)); - this.cache.set(id, next); - await this.enqueuePersist(); - return next; + return this.mutate((records) => { + const existing = records.get(id); + if (!existing) return null; + const next = this.schema.parse(updater(existing)); + records.set(id, next); + return next; + }); } async archive(id: string, archivedAt: string): Promise { @@ -237,29 +277,30 @@ class FileBackedRegistry { } protected async archiveIfPresent(id: string, archivedAt: string): Promise { - await this.load(); - const existing = this.cache.get(id); - if (!existing) return null; - return this.persistArchive(existing, archivedAt); + return this.mutate((records) => this.archiveRecord(records, id, archivedAt)); } protected async archiveIfActive(id: string, archivedAt: string): Promise { - await this.load(); - const existing = this.cache.get(id); - if (!existing || existing.archivedAt) { - return null; - } - return this.persistArchive(existing, archivedAt); + return this.mutate((records) => { + const existing = records.get(id); + if (!existing || existing.archivedAt) return null; + return this.archiveRecord(records, id, archivedAt); + }); } - private async persistArchive(existing: TRecord, archivedAt: string): Promise { + private archiveRecord( + records: Map, + id: string, + archivedAt: string, + ): TRecord | null { + const existing = records.get(id); + if (!existing) return null; const next = this.schema.parse({ ...existing, updatedAt: archivedAt, archivedAt, }); - this.cache.set(this.getId(next), next); - await this.enqueuePersist(); + records.set(this.getId(next), next); return next; } @@ -268,14 +309,12 @@ class FileBackedRegistry { } protected async removeIfPresent(id: string): Promise { - await this.load(); - const existing = this.cache.get(id); - if (!existing) { - return null; - } - this.cache.delete(id); - await this.enqueuePersist(); - return existing; + return this.mutate((records) => { + const existing = records.get(id); + if (!existing) return null; + records.delete(id); + return existing; + }); } private async load(): Promise { @@ -299,18 +338,43 @@ class FileBackedRegistry { this.loaded = true; } - private async persist(): Promise { - const records = Array.from(this.cache.values()); - await writeJsonFileAtomic(this.filePath, records); + private async persist(records: ReadonlyMap): Promise { + await writeJsonFileAtomic(this.filePath, Array.from(records.values())); } - private async enqueuePersist(): Promise { - const nextPersist = this.persistQueue.then(() => this.persist()); - this.persistQueue = nextPersist.catch(() => {}); - await nextPersist; + private async mutate( + mutation: (records: Map) => TResult, + ): Promise { + await this.load(); + const previous = this.mutationQueue; + let release!: () => void; + this.mutationQueue = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + const next = new Map(this.cache); + const result = mutation(next); + if (mapsEqual(this.cache, next)) return result; + await this.persist(next); + this.cache.clear(); + for (const [id, record] of next) this.cache.set(id, record); + return result; + } finally { + release(); + } } } +function mapsEqual( + left: ReadonlyMap, + right: ReadonlyMap, +): boolean { + if (left.size !== right.size) return false; + for (const [key, value] of left) if (right.get(key) !== value) return false; + return true; +} + export class FileBackedProjectRegistry extends FileBackedRegistry implements ProjectRegistry @@ -358,11 +422,11 @@ export class FileBackedProjectRegistry left.projectId.localeCompare(right.projectId), )[0]; if (active) { - if (active.kind === input.kind && active.projectKey === (input.projectKey ?? null)) - return active; + const kind = active.source ? "git" : input.kind; + if (active.kind === kind && active.projectKey === (input.projectKey ?? null)) return active; const refreshed = { ...active, - kind: input.kind, + kind, projectKey: input.projectKey ?? null, updatedAt: input.timestamp, }; @@ -462,11 +526,20 @@ export class FileBackedWorkspaceRegistry return () => this.mutationListeners.delete(listener); } + override async list(): Promise { + const workspaces = await super.list(); + return workspaces.filter((workspace) => workspace.materializingAt === undefined); + } + override async update( workspaceId: string, updater: (record: PersistedWorkspaceRecord) => PersistedWorkspaceRecord, ): Promise { - const workspace = await super.update(workspaceId, updater); + const workspace = await super.update(workspaceId, (record) => { + const updated = updater(record); + const { materializingAt: _materializingAt, ...published } = updated; + return published; + }); if (workspace) { await this.notifyMutation({ kind: "upsert", workspaceId, workspace }); } @@ -477,12 +550,17 @@ export class FileBackedWorkspaceRegistry record: PersistedWorkspaceRecord, context?: WorkspaceMutationContext, ): Promise { - await super.upsert(record); + const { materializingAt: _materializingAt, ...withoutMaterializingState } = record; + const persisted = context?.provisional + ? { ...withoutMaterializingState, materializingAt: record.updatedAt } + : withoutMaterializingState; + await super.upsert(persisted); await this.notifyMutation({ kind: "upsert", workspaceId: record.workspaceId, - workspace: record, + workspace: persisted, ...(context?.expectsInitialAgent ? { expectsInitialAgent: true } : {}), + ...(context?.provisional ? { provisional: true } : {}), }); } @@ -509,6 +587,16 @@ export class FileBackedWorkspaceRegistry await this.notifyMutation({ kind: "remove", workspaceId, workspace: null }); } + async requestDeletion(workspaceId: string, requestedAt: string): Promise { + const workspace = await super.update(workspaceId, (existing) => ({ + ...existing, + deletionRequestedAt: existing.deletionRequestedAt ?? requestedAt, + updatedAt: existing.deletionRequestedAt ? existing.updatedAt : requestedAt, + })); + if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`); + await this.notifyMutation({ kind: "upsert", workspaceId, workspace }); + } + private async notifyMutation(mutation: WorkspaceMutation): Promise { await Promise.all([...this.mutationListeners].map((listener) => listener(mutation))); } @@ -519,6 +607,7 @@ export function createPersistedProjectRecord(input: { rootPath: string; kind: PersistedProjectKind; displayName: string; + source?: PersistedProjectRecord["source"]; customName?: string | null; projectKey?: string | null; customIconRevision?: string | null; @@ -535,6 +624,17 @@ export function createPersistedProjectRecord(input: { }); } +/** The only conversion from host project persistence to runtime materialization authority. */ +export function projectRuntimeSource( + project: Pick, +): WorkspaceProjectSource { + if (!project.source) return { kind: "host-directory", path: project.rootPath }; + return { + ...project.source, + revision: project.source.revision ?? "", + }; +} + export function resolveProjectDisplayName(record: PersistedProjectRecord): string { return record.customName ?? record.displayName; } @@ -551,6 +651,8 @@ export function createPersistedWorkspaceRecord(input: { baseBranch?: string | null; isPaseoOwnedWorktree?: boolean; mainRepoRoot?: string | null; + runtime?: { runtimeId: string }; + deletionRequestedAt?: string | null; createdAt: string; updatedAt: string; archivedAt?: string | null; @@ -565,6 +667,8 @@ export function createPersistedWorkspaceRecord(input: { baseBranch: input.baseBranch ?? null, isPaseoOwnedWorktree: input.isPaseoOwnedWorktree ?? false, mainRepoRoot: input.mainRepoRoot ?? null, + ...(input.runtime ? { runtime: input.runtime } : {}), + deletionRequestedAt: input.deletionRequestedAt ?? null, archivedAt: input.archivedAt ?? null, autoArchivedChangeRequestUrl: input.autoArchivedChangeRequestUrl ?? null, pinnedAt: input.pinnedAt ?? null, diff --git a/packages/server/src/server/workspace-runtime/command/index.ts b/packages/server/src/server/workspace-runtime/command/index.ts new file mode 100644 index 0000000000..13648d192b --- /dev/null +++ b/packages/server/src/server/workspace-runtime/command/index.ts @@ -0,0 +1,24 @@ +import type { WorkspaceRuntimeDriver } from "../drivers/index.js"; +import type { WorkspaceRuntimeJsonValue } from "../index.js"; +import { createCommandRuntime } from "./internal/command-runtime.js"; + +export interface CommandRuntimeAdapterConfig { + command: readonly [string, ...string[]]; + options?: Readonly>; +} + +export function createCommandRuntimeAdapter( + runtimeId: string, + config: CommandRuntimeAdapterConfig, + runtimeInstanceId: string, + packageResolutionBase: string, + pathResolutionBase: string, +): WorkspaceRuntimeDriver { + return createCommandRuntime( + runtimeId, + config, + runtimeInstanceId, + packageResolutionBase, + pathResolutionBase, + ); +} 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 new file mode 100644 index 0000000000..31a92bf9b7 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts @@ -0,0 +1,817 @@ +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { constants } from "node:os"; +import { createRequire } from "node:module"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import type { Readable, Writable } from "node:stream"; +import { StringDecoder } from "node:string_decoder"; + +import { + COMMAND_RUNTIME_PROTOCOL_VERSION, + CommandRuntimeControlSchema, + CommandRuntimeDescribeResponseSchema, + CommandRuntimeLifecycleRequestSchema, + CommandRuntimeLifecycleResponseSchema, + createCommandRuntimeProcessEventDecoder, + encodeCommandRuntimeMessage, + type CommandRuntimeProcessEvent, +} from "@getpaseo/workspace-runtime-contract"; + +import type { + WorkspaceDriverCreateInput, + WorkspaceDriverInspection, + WorkspaceDriverState, + WorkspaceDriverSpawnInput, + WorkspacePipeProcess, + WorkspacePtyProcess, + WorkspaceRuntimeDriver, +} from "../../drivers/index.js"; +import type { WorkspaceRuntimeJsonValue } from "../../index.js"; +const COMMAND_RUNTIME_CLEANUP_TIMEOUT_MS = 750; + +export interface CommandRuntimeConfig { + command: readonly [string, ...string[]]; + options?: Readonly>; +} + +export function createCommandRuntime( + runtimeId: string, + config: CommandRuntimeConfig, + runtimeInstanceId: string, + packageResolutionBase: string, + pathResolutionBase: string, +): WorkspaceRuntimeDriver { + const command = resolveRuntimeCommand(config.command, packageResolutionBase, pathResolutionBase); + let described: Promise> | null = null; + let supportsReconciliation = false; + + function describe(): Promise> { + described ??= runCommand(["describe"], undefined).then((output) => { + const value: unknown = JSON.parse(output); + assertProtocolVersion(runtimeId, value); + const description = CommandRuntimeDescribeResponseSchema.parse(value); + if (!description.modes.includes("pipes")) { + throw new Error(`Workspace runtime ${runtimeId} does not support pipes`); + } + supportsReconciliation = description.reconcile; + return new Set(description.modes); + }); + return described; + } + + async function lifecycle( + operation: "create" | "inspect" | "pause" | "resume" | "destroy", + workspaceId: string, + input?: WorkspaceDriverCreateInput, + ) { + await describe(); + const output = await runCommand( + [operation, "--workspace-id", workspaceId], + encodeCommandRuntimeMessage(CommandRuntimeLifecycleRequestSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + runtimeInstanceId, + input: input ? commandCreateInput(input) : undefined, + options: config.options ?? {}, + }), + ); + const value: unknown = JSON.parse(output); + assertProtocolVersion(runtimeId, value); + return CommandRuntimeLifecycleResponseSchema.parse(value); + } + + return { + id: runtimeId, + requiresGitProject: true, + reconciliationDomainId: JSON.stringify({ command, options: config.options }), + workspaceHelper: { + command: ["paseo-workspace-helper"], + env: {}, + }, + scriptTerminal: { kind: "direct-command", command: "/bin/sh", argsPrefix: ["-lc"] }, + provider: { environment: "isolated", sharedHostProviders: new Set() }, + async create(input) { + const response = await lifecycle("create", input.workspaceId, input); + if (response.type !== "state") throw new Error(`Invalid create response from ${runtimeId}`); + if (response.materializedFreshContent === undefined) { + throw new Error( + `Workspace runtime ${runtimeId} create response is missing materializedFreshContent`, + ); + } + return { + ...commandReady(response.state, response.placement), + materializedFreshContent: response.materializedFreshContent, + }; + }, + async inspect(workspaceId): Promise { + const response = await lifecycle("inspect", workspaceId); + if (response.type !== "inspection") { + throw new Error(`Invalid inspect response from ${runtimeId}`); + } + if (response.inspection.status === "ready" || response.inspection.status === "paused") { + return { + ...response.inspection, + ...commandReady(response.inspection.state, response.inspection.placement), + }; + } + return response.inspection as WorkspaceDriverInspection; + }, + async spawn(input) { + const modes = await describe(); + if (input.stdio.kind === "pty") { + if (!modes.has("pty")) { + throw new Error(`Workspace runtime ${runtimeId} does not support PTY mode`); + } + return spawnCommandPty(runtimeId, command, input, config.options ?? {}, runtimeInstanceId); + } + return spawnCommandProcess( + runtimeId, + command, + input, + config.options ?? {}, + runtimeInstanceId, + ); + }, + async pause(workspaceId) { + const response = await lifecycle("pause", workspaceId); + if (response.type !== "ok") throw new Error(`Invalid pause response from ${runtimeId}`); + }, + async resume(workspaceId) { + const response = await lifecycle("resume", workspaceId); + if (response.type !== "state") throw new Error(`Invalid resume response from ${runtimeId}`); + return commandReady(response.state, response.placement); + }, + async destroy(workspaceId) { + const response = await lifecycle("destroy", workspaceId); + if (response.type !== "ok") throw new Error(`Invalid destroy response from ${runtimeId}`); + }, + async reconcile(workspaceIds) { + await describe(); + if (!supportsReconciliation) return; + const output = await runCommand( + ["reconcile"], + encodeCommandRuntimeMessage(CommandRuntimeLifecycleRequestSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + runtimeInstanceId, + workspaceIds, + options: config.options ?? {}, + }), + ); + const value: unknown = JSON.parse(output); + assertProtocolVersion(runtimeId, value); + const response = CommandRuntimeLifecycleResponseSchema.parse(value); + if (response.type !== "ok") throw new Error(`Invalid reconcile response from ${runtimeId}`); + }, + }; + + async function runCommand(args: string[], stdin: string | undefined): Promise { + const child = spawn(command[0], [...command.slice(1), ...args], { + env: commandEnvironment(), + shell: false, + stdio: ["pipe", "pipe", "pipe"], + }); + if (stdin === undefined) child.stdin.end(); + else child.stdin.end(stdin); + const [stdout, stderr, exit] = await Promise.all([ + collect(child.stdout), + collect(child.stderr), + new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => resolve({ code, signal })); + }), + ]); + if (exit.code !== 0) { + throw new Error( + `Workspace runtime ${runtimeId} ${args[0]} failed (${exit.code ?? exit.signal}): ${stderr.trim()}`, + ); + } + return stdout; + } +} + +function commandReady(state: WorkspaceDriverState, placement: { cwd: string } | undefined) { + if (!placement) { + throw new Error("Workspace runtime did not return public placement"); + } + return { state, placement }; +} + +function commandCreateInput(input: WorkspaceDriverCreateInput) { + return { + ...input, + project: { + ...input.project, + source: + input.project.source.kind === "host-directory" + ? { kind: "directory" as const, path: input.project.source.path } + : input.project.source, + }, + purpose: input.purpose === "provider-probe" ? ("discovery" as const) : undefined, + }; +} + +function commandPurpose(input: WorkspaceDriverSpawnInput["purpose"]) { + switch (input.kind) { + case "provider-probe": + return { kind: "discovery" as const }; + case "agent": + case "terminal": + case "workspace-script": + return { kind: input.kind }; + default: + return input; + } +} + +function assertProtocolVersion(runtimeId: string, value: unknown): void { + const version = + typeof value === "object" && value !== null && "protocolVersion" in value + ? value.protocolVersion + : undefined; + if (version === COMMAND_RUNTIME_PROTOCOL_VERSION) return; + throw new Error( + `Workspace runtime ${runtimeId} uses unsupported command protocol version ${String(version)}; expected ${COMMAND_RUNTIME_PROTOCOL_VERSION}`, + ); +} + +function spawnCommandPty( + runtimeId: string, + command: readonly [string, ...string[]], + input: WorkspaceDriverSpawnInput, + options: Readonly>, + runtimeInstanceId: string, +): WorkspacePtyProcess { + if (input.stdio.kind !== "pty") throw new Error("PTY spawn requires PTY stdio"); + const execId = randomBytes(16).toString("hex"); + const child = spawn( + command[0], + [...command.slice(1), "exec", "--workspace-id", input.workspaceId], + { + env: commandEnvironment(), + detached: process.platform !== "win32", + shell: false, + stdio: ["pipe", "pipe", "pipe", "pipe", "pipe"], + }, + ); + const control = child.stdio[3] as Writable; + const events = child.stdio[4] as Readable; + const listeners = new Set<(data: string) => void>(); + let pendingData = ""; + const decoder = new StringDecoder("utf8"); + let stderr = ""; + let resizeId = 0; + let pendingResizeId: number | null = null; + let resizeTimeout: ReturnType | null = null; + let pendingWrites = ""; + let settled = false; + let workloadExit: { code: number | null; signal: NodeJS.Signals | null } | null = null; + let eventsEnded = false; + let wrapperClosed = false; + let resolveWrapperClosed!: () => void; + const wrapperClosedPromise = new Promise((resolve) => { + resolveWrapperClosed = resolve; + }); + let wrapperExit: { code: number | null; signal: NodeJS.Signals | null } | null = null; + let failureCleanup: Promise | null = null; + let resolveExit!: (exit: { code: number | null; signal: NodeJS.Signals | null }) => void; + let rejectExit!: (error: Error) => void; + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + resolveExit = resolve; + rejectExit = reject; + }, + ); + exited.catch(() => undefined); + + child.stdout.on("data", (chunk: Buffer) => emitData(decoder.write(chunk))); + child.stdout.once("end", () => emitData(decoder.end())); + child.stderr.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + void readProcessEvents(events, "pty", (event) => { + if (event.type === "started") { + return; + } + if (event.type === "eof") { + return; + } + if (event.type === "resized") { + if (event.id !== pendingResizeId) return; + pendingResizeId = null; + if (resizeTimeout) clearTimeout(resizeTimeout); + resizeTimeout = null; + if (pendingWrites) { + child.stdin.write(pendingWrites); + pendingWrites = ""; + } + return; + } + if (event.type === "error") { + failPty(new Error(`Workspace runtime ${runtimeId} PTY failed: ${event.message}`)); + return; + } + workloadExit = { code: event.code, signal: parseSignal(runtimeId, event.signal) }; + child.stdin.end(); + control.end(); + }).then( + () => { + eventsEnded = true; + finishFromAuthoritativeExit(); + return undefined; + }, + (error) => { + failPty(error); + return undefined; + }, + ); + control.once("error", failPty); + child.stdin.once("error", failPty); + child.once("error", (error) => { + failPty(new Error(`Workspace runtime ${runtimeId} PTY failed: ${error.message}`)); + }); + child.once("close", (code, signal) => { + wrapperClosed = true; + wrapperExit = { code, signal }; + resolveWrapperClosed(); + finishFromAuthoritativeExit(); + }); + writeControl( + CommandRuntimeControlSchema.parse({ + type: "spawn", + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + argv: input.argv, + cwd: input.cwd, + env: input.env, + purpose: commandPurpose(input.purpose), + options, + runtimeInstanceId, + execId, + stdio: input.stdio, + }), + ); + + return { + kind: "pty", + onData(listener) { + listeners.add(listener); + if (pendingData) { + const data = pendingData; + pendingData = ""; + listener(data); + } + return () => listeners.delete(listener); + }, + write(data) { + if (pendingResizeId === null) child.stdin.write(data); + else pendingWrites += data; + }, + resize(cols, rows) { + const controlValue = CommandRuntimeControlSchema.parse({ + type: "resize", + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + id: resizeId + 1, + cols, + rows, + }); + resizeId += 1; + pendingResizeId = resizeId; + writeControl(controlValue); + if (resizeTimeout) clearTimeout(resizeTimeout); + resizeTimeout = setTimeout( + () => failPty(new Error("PTY resize acknowledgement timed out")), + 1000, + ); + }, + exited, + kill(signal = "SIGTERM") { + if (settled || failureCleanup) return; + writeControl( + CommandRuntimeControlSchema.parse({ + type: "signal", + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + signal, + }), + ); + }, + }; + + function emitData(data: string): void { + if (!data) return; + if (listeners.size === 0) { + pendingData += data; + return; + } + for (const listener of listeners) listener(data); + } + + function writeControl(value: unknown): void { + try { + control.write(encodeCommandRuntimeMessage(CommandRuntimeControlSchema, value)); + } catch (error) { + failPty(error); + } + } + + function finishFromAuthoritativeExit(): void { + if (settled || failureCleanup || !eventsEnded || !wrapperClosed) return; + if (!workloadExit) { + const detail = stderr.trim() || wrapperExit?.signal || wrapperExit?.code || "unknown"; + failPty( + new Error( + `Workspace runtime ${runtimeId} PTY wrapper ended without a valid fd4 exit event (${detail})`, + ), + ); + return; + } + settled = true; + resolveExit(workloadExit); + } + + function failPty(error: unknown): void { + if (settled || failureCleanup) return; + if (resizeTimeout) clearTimeout(resizeTimeout); + resizeTimeout = null; + control.destroy(); + events.destroy(); + child.stdin.destroy(); + const failure = error instanceof Error ? error : new Error(String(error)); + failureCleanup = (async () => { + let cleanupError: unknown; + try { + await forceKillCommandRuntime( + command, + input.workspaceId, + execId, + options, + runtimeInstanceId, + child, + wrapperClosedPromise, + ); + } catch (cleanupFailure) { + cleanupError = cleanupFailure; + } + settled = true; + const cleanupDetail = + cleanupError instanceof Error ? `; cleanup failed: ${cleanupError.message}` : ""; + const wrapperDetail = stderr.trim() ? `; wrapper: ${stderr.trim()}` : ""; + rejectExit( + new Error( + `Workspace runtime ${runtimeId} PTY failed: ${failure.message}${wrapperDetail}${cleanupDetail}`, + ), + ); + })(); + } +} + +async function forceKillCommandRuntime( + command: readonly [string, ...string[]], + workspaceId: string, + execId: string, + options: Readonly>, + runtimeInstanceId: string, + wrapper: { pid?: number; kill(signal?: NodeJS.Signals): boolean }, + wrapperClosed: Promise, +): Promise { + const signalCommand = spawn( + command[0], + [ + ...command.slice(1), + "signal", + "--workspace-id", + workspaceId, + "--exec-id", + execId, + "--signal", + "SIGKILL", + ], + { + env: commandEnvironment(), + detached: process.platform !== "win32", + 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) => { + if (finished) return; + finished = true; + resolve(error); + }; + signalCommand.once("error", (error) => finish(error)); + signalCommand.stdin.once("error", (error) => finish(error)); + signalCommand.once("close", (code, signal) => + finish( + code === 0 + ? null + : new Error( + `signal helper failed (${code ?? signal ?? "unknown"})${signalStderr.trim() ? `: ${signalStderr.trim()}` : ""}`, + ), + ), + ); + }); + signalCommand.stdin.end( + encodeCommandRuntimeMessage(CommandRuntimeLifecycleRequestSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + runtimeInstanceId, + options, + }), + ); + const helper = await waitBounded(signalResult, COMMAND_RUNTIME_CLEANUP_TIMEOUT_MS); + let cleanupError: Error | null = null; + if (helper.timedOut) { + cleanupError = new Error("signal helper timed out"); + killOwnedProcessGroup(signalCommand, "SIGKILL"); + } else { + cleanupError = helper.value; + if (cleanupError) killOwnedProcessGroup(signalCommand, "SIGKILL"); + } + killOwnedProcessGroup(wrapper, "SIGKILL"); + const wrapperResult = await waitBounded(wrapperClosed, COMMAND_RUNTIME_CLEANUP_TIMEOUT_MS); + if (wrapperResult.timedOut) { + const wrapperError = new Error("wrapper remained alive after SIGKILL"); + throw cleanupError ? new AggregateError([cleanupError, wrapperError]) : wrapperError; + } + if (cleanupError) throw cleanupError; +} + +async function waitBounded( + promise: Promise, + timeoutMs: number, +): Promise<{ timedOut: false; value: T } | { timedOut: true }> { + let timeout: ReturnType | null = null; + const result = await Promise.race([ + promise.then((value) => ({ timedOut: false as const, value })), + new Promise<{ timedOut: true }>((resolve) => { + timeout = setTimeout(() => resolve({ timedOut: true }), timeoutMs); + }), + ]); + if (timeout) clearTimeout(timeout); + return result; +} + +function isNodeSignal(value: string): value is NodeJS.Signals { + return value in constants.signals; +} + +function parseSignal(runtimeId: string, signal: string | null): NodeJS.Signals | null { + if (signal === null) return null; + if (!isNodeSignal(signal)) { + throw new Error(`Workspace runtime ${runtimeId} returned an invalid signal: ${signal}`); + } + return signal; +} + +async function readProcessEvents( + stream: Readable, + mode: "pipes" | "pty", + receive: (value: CommandRuntimeProcessEvent) => void, +): Promise { + const decoder = createCommandRuntimeProcessEventDecoder(mode); + for await (const chunk of stream) { + for (const value of decoder.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) { + receive(value); + } + } + decoder.finish(); +} + +function spawnCommandProcess( + runtimeId: string, + command: readonly [string, ...string[]], + input: WorkspaceDriverSpawnInput, + options: Readonly>, + runtimeInstanceId: string, +): WorkspacePipeProcess { + const execId = randomBytes(16).toString("hex"); + const child = spawn( + command[0], + [...command.slice(1), "exec", "--workspace-id", input.workspaceId], + { + env: commandEnvironment(), + detached: process.platform !== "win32", + shell: false, + stdio: ["pipe", "pipe", "pipe", "pipe", "pipe"], + }, + ); + const metadata = child.stdio[3] as Writable; + const events = child.stdio[4] as Readable; + let stderr = ""; + let signalable = false; + let pendingSignal: NodeJS.Signals | null = null; + let workloadExit: { code: number | null; signal: NodeJS.Signals | null } | null = null; + let eventsEnded = false; + let wrapperClosed = false; + let wrapperExit: { code: number | null; signal: NodeJS.Signals | null } | null = null; + let settled = false; + let cleanup: Promise | null = null; + let resolveWrapperClosed!: () => void; + const wrapperClosedPromise = new Promise((resolve) => { + resolveWrapperClosed = resolve; + }); + let resolveExit!: (exit: { code: number | null; signal: NodeJS.Signals | null }) => void; + let rejectExit!: (error: Error) => void; + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + resolveExit = resolve; + rejectExit = reject; + }, + ); + exited.catch(() => undefined); + child.stderr.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + void readProcessEvents(events, "pipes", (event) => { + if (event.type === "started") { + signalable = true; + if (pendingSignal) deliverSignal(pendingSignal); + return; + } + if (event.type === "eof") { + return; + } + if (event.type === "error") { + fail(new Error(event.message)); + return; + } + if (event.type === "resized") return; + workloadExit = { code: event.code, signal: parseSignal(runtimeId, event.signal) }; + }).then( + () => { + eventsEnded = true; + finish(); + return undefined; + }, + (error) => { + fail(error); + return undefined; + }, + ); + metadata.once("error", fail); + child.once("error", (error) => fail(new Error(`exec failed: ${error.message}`))); + child.once("exit", (code, signal) => { + wrapperClosed = true; + wrapperExit = { code, signal }; + resolveWrapperClosed(); + finish(); + }); + metadata.end( + encodeCommandRuntimeMessage(CommandRuntimeControlSchema, { + type: "spawn", + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + argv: input.argv, + cwd: input.cwd, + env: input.env, + purpose: commandPurpose(input.purpose), + options, + runtimeInstanceId, + execId, + stdio: input.stdio, + }), + ); + + return { + kind: "pipes", + stdin: child.stdin, + stdout: child.stdout, + stderr: child.stderr, + exited, + kill(signal = "SIGTERM") { + if (settled || cleanup) return; + pendingSignal = signal; + if (signalable) deliverSignal(signal); + }, + }; + + function deliverSignal(signal: NodeJS.Signals): void { + pendingSignal = null; + if (signal === "SIGKILL") { + cleanup = (async () => { + try { + await forceKillCommandRuntime( + command, + input.workspaceId, + execId, + options, + runtimeInstanceId, + 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 { + if (settled || cleanup || !eventsEnded || !wrapperClosed) return; + if (!workloadExit) { + const detail = stderr.trim() || wrapperExit?.signal || wrapperExit?.code || "unknown"; + fail(new Error(`pipes wrapper ended without a valid fd4 exit event (${String(detail)})`)); + return; + } + settled = true; + resolveExit(workloadExit); + } + + function fail(error: unknown): void { + if (settled || cleanup) return; + metadata.destroy(); + events.destroy(); + child.stdin.destroy(); + const failure = error instanceof Error ? error : new Error(String(error)); + cleanup = (async () => { + let cleanupError: unknown; + try { + await forceKillCommandRuntime( + command, + input.workspaceId, + execId, + options, + runtimeInstanceId, + child, + wrapperClosedPromise, + ); + } catch (caught) { + cleanupError = caught; + } + settled = true; + const detail = + cleanupError instanceof Error ? `; cleanup failed: ${cleanupError.message}` : ""; + const wrapperDetail = stderr.trim() ? `; wrapper: ${stderr.trim()}` : ""; + rejectExit( + new Error( + `Workspace runtime ${runtimeId} pipes failed: ${failure.message}${wrapperDetail}${detail}`, + ), + ); + })(); + } +} + +function killOwnedProcessGroup( + child: { pid?: number; kill(signal?: NodeJS.Signals): boolean }, + signal: NodeJS.Signals, +): void { + try { + if (process.platform === "win32" || !child.pid) child.kill(signal); + else process.kill(-child.pid, signal); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } +} + +async function collect(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +function commandEnvironment(): NodeJS.ProcessEnv { + const env = { ...process.env }; + if (env.FORCE_COLOR !== undefined) delete env.NO_COLOR; + return env; +} + +function resolveRuntimeCommand( + command: readonly [string, ...string[]], + packageResolutionBase: string, + pathResolutionBase: string, +): readonly [string, ...string[]] { + const executable = command[0]; + const moduleRequire = createRequire(path.join(packageResolutionBase, "package.json")); + let resolved = executable; + if (executable.startsWith("@")) { + const packageJsonPath = moduleRequire.resolve(`${executable}/package.json`); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { + bin?: string | Record; + }; + const bin = + typeof packageJson.bin === "string" + ? packageJson.bin + : packageJson.bin && Object.values(packageJson.bin)[0]; + if (!bin) throw new Error(`Workspace runtime package has no executable: ${executable}`); + resolved = path.resolve(path.dirname(packageJsonPath), bin); + if ([".js", ".cjs", ".mjs"].includes(path.extname(resolved))) { + return [process.execPath, resolved, ...command.slice(1)]; + } + } else if (path.isAbsolute(executable)) { + resolved = path.normalize(executable); + } else if (executable.startsWith(".")) { + resolved = path.resolve(pathResolutionBase, executable); + } + return [resolved, ...command.slice(1)]; +} diff --git a/packages/server/src/server/workspace-runtime/drivers/index.ts b/packages/server/src/server/workspace-runtime/drivers/index.ts new file mode 100644 index 0000000000..8106d949ef --- /dev/null +++ b/packages/server/src/server/workspace-runtime/drivers/index.ts @@ -0,0 +1,124 @@ +import type { Readable, Writable } from "node:stream"; + +export type WorkspaceRuntimeId = string; +export type WorkspaceId = string; + +export type WorkspaceProjectSource = import("../index.js").WorkspaceProjectSource; + +export type WorkspacePlacementIntent = + | { kind: "existing"; relativeCwd?: string } + | { + kind: "branch"; + branchName: string; + baseRef: string; + relativeCwd?: string; + worktreeSlug?: string; + } + | { kind: "checkout"; ref: string; relativeCwd?: string; worktreeSlug?: string } + | import("../index.js").ResolvedWorktreePlacement; + +export interface WorkspaceDriverCreateInput { + workspaceId: WorkspaceId; + project: { projectId: string; source: WorkspaceProjectSource }; + placement: WorkspacePlacementIntent; + purpose?: "provider-probe"; + markFirstAgentBranchAutoName?: boolean; + seedPaseoConfigFrom?: string; +} + +export interface WorkspaceDriverState { + workspaceId: WorkspaceId; + lifecycle: "ready" | "paused"; + lifecycleEnvironment?: Readonly>; +} + +export interface WorkspacePublicPlacement { + cwd: string; + hostVisiblePath?: string; +} + +export interface WorkspaceDriverReady { + state: WorkspaceDriverState; + placement: WorkspacePublicPlacement; +} + +export interface WorkspaceDriverCreation extends WorkspaceDriverReady { + /** This create materialized fresh workspace content/resources, so user setup may run. */ + materializedFreshContent: boolean; +} + +export type WorkspaceDriverInspection = + | { status: "missing" } + | ({ status: "paused" } & WorkspaceDriverReady) + | ({ status: "ready" } & WorkspaceDriverReady) + | { status: "error"; message: string }; + +export interface WorkspaceDriverSpawnInput { + workspaceId: WorkspaceId; + cwd?: string; + argv: readonly [string, ...string[]]; + env: Readonly>; + purpose: + | { kind: "agent"; agentId: string; provider: string } + | { kind: "terminal"; terminalId: string } + | { kind: "git" } + | { kind: "provider-probe"; provider: string } + | { kind: "workspace-helper" } + | { kind: "workspace-script"; script: string } + | { kind: "setup" } + | { kind: "archive" }; + stdio: { kind: "pipes" } | { kind: "pty"; rows: number; cols: number; term?: string }; +} + +export interface WorkspaceProcessExit { + code: number | null; + signal: NodeJS.Signals | null; +} + +export interface WorkspacePipeProcess { + kind: "pipes"; + stdin: Writable; + stdout: Readable; + stderr: Readable; + exited: Promise; + kill(signal?: NodeJS.Signals): void; +} + +export interface WorkspacePtyProcess { + kind: "pty"; + onData(listener: (data: string) => void): () => void; + write(data: string): void; + resize(cols: number, rows: number): void; + exited: Promise; + kill(signal?: NodeJS.Signals): void; +} + +export type WorkspaceDriverProcess = WorkspacePipeProcess | WorkspacePtyProcess; + +export interface WorkspaceRuntimeDriver { + readonly id: WorkspaceRuntimeId; + readonly requiresGitProject: boolean; + readonly reconciliationDomainId?: string; + /** Runtime-local launch specification for Paseo's compatible workspace helper. */ + readonly workspaceHelper: { + command: readonly [string, ...string[]]; + env: Readonly>; + }; + readonly scriptTerminal: import("../index.js").WorkspaceScriptTerminal; + readonly provider: import("../index.js").WorkspaceRuntimeProviderCapability; + /** Private setup-only base environment owned by this execution boundary. */ + setupEnvironment?(): Readonly>; + create(input: WorkspaceDriverCreateInput): Promise; + inspect(workspaceId: WorkspaceId): Promise; + spawn(input: WorkspaceDriverSpawnInput): Promise; + observeGit?( + workspaceId: WorkspaceId, + listener: () => void, + ): Promise<{ unsubscribe(): Promise }>; + pause(workspaceId: WorkspaceId): Promise; + /** Release restorable backing owned by this driver after the runtime is paused. */ + releaseBacking?(workspaceId: WorkspaceId): Promise; + resume(workspaceId: WorkspaceId): Promise; + destroy(workspaceId: WorkspaceId): Promise; + reconcile?(workspaceIds: readonly WorkspaceId[]): Promise; +} diff --git a/packages/server/src/server/workspace-runtime/git-observation/index.ts b/packages/server/src/server/workspace-runtime/git-observation/index.ts new file mode 100644 index 0000000000..728d5364a3 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/git-observation/index.ts @@ -0,0 +1,10 @@ +import type { BoundWorkspaceRuntime } from "../index.js"; +import { requireGitCommonObservationCapability } from "./internal/capability.js"; + +/** Observe metadata shared by Git worktrees without exposing its runtime placement. */ +export function observeGitCommonMetadata( + runtime: BoundWorkspaceRuntime, + listener: () => void, +): Promise<{ unsubscribe(): Promise }> { + return requireGitCommonObservationCapability(runtime).observe(listener); +} diff --git a/packages/server/src/server/workspace-runtime/git-observation/internal/capability.ts b/packages/server/src/server/workspace-runtime/git-observation/internal/capability.ts new file mode 100644 index 0000000000..01010dd195 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/git-observation/internal/capability.ts @@ -0,0 +1,22 @@ +import type { BoundWorkspaceRuntime } from "../../index.js"; + +export interface GitCommonObservationCapability { + observe(listener: () => void): Promise<{ unsubscribe(): Promise }>; +} + +const capabilities = new WeakMap(); + +export function registerGitCommonObservationCapability( + runtime: BoundWorkspaceRuntime, + capability: GitCommonObservationCapability, +): void { + capabilities.set(runtime, capability); +} + +export function requireGitCommonObservationCapability( + runtime: BoundWorkspaceRuntime, +): GitCommonObservationCapability { + const capability = capabilities.get(runtime); + if (!capability) throw new Error("Bound workspace runtime cannot observe Git common metadata"); + return capability; +} diff --git a/packages/server/src/server/workspace-runtime/git-observation/internal/integration.test.ts b/packages/server/src/server/workspace-runtime/git-observation/internal/integration.test.ts new file mode 100644 index 0000000000..a59ad7568a --- /dev/null +++ b/packages/server/src/server/workspace-runtime/git-observation/internal/integration.test.ts @@ -0,0 +1,125 @@ +import { PassThrough, Readable } from "node:stream"; + +import { expect, test, vi } from "vitest"; + +import { observeWorkspaceGit } from "../../../workspace-git-observation.js"; +import type { BoundWorkspaceRuntime } from "../../index.js"; +import type { WorkspaceRuntimeDriver } from "../../drivers/index.js"; +import { createGitCommonObservationCoordinator } from "./integration.js"; + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +test("selected runtime Git observation prunes paths ignored by that runtime checkout", async () => { + let subscriptionInput: Parameters[0] | null = null; + const run = vi.fn(async () => ({ + stdin: new PassThrough(), + stdout: Readable.from(["node_modules/\0packages/server/dist/\0"]), + stderr: Readable.from([]), + exited: Promise.resolve({ code: 0, signal: null }), + kill: () => undefined, + })); + const resolveCommand = vi.fn(async () => "C:\\runtime\\git.exe"); + const runtime = { + run, + resolveCommand, + scriptTerminal: { kind: "direct-command", command: "/bin/sh", argsPrefix: ["-lc"] }, + files: { + subscribe: async (input) => { + subscriptionInput = input; + return { unsubscribe: async () => undefined }; + }, + }, + } as unknown as BoundWorkspaceRuntime; + const coordinator = createGitCommonObservationCoordinator(); + coordinator.bind(runtime, "workspace-1", {} as WorkspaceRuntimeDriver); + + const subscription = await observeWorkspaceGit(runtime, () => undefined); + + expect(subscriptionInput).toEqual({ + paths: ["."], + recursive: true, + ignoredPaths: ["node_modules", "packages/server/dist"], + }); + expect(resolveCommand).toHaveBeenCalledWith("git"); + expect(run).toHaveBeenCalledWith({ + argv: [ + "C:\\runtime\\git.exe", + "ls-files", + "--others", + "--ignored", + "--exclude-standard", + "--directory", + "-z", + ], + env: { GIT_OPTIONAL_LOCKS: "0" }, + purpose: { kind: "git" }, + }); + await subscription.unsubscribe(); +}); + +test("selected runtime Git observation stays available when Git is unavailable", async () => { + let subscriptionInput: Parameters[0] | null = null; + const runtime = { + run: vi.fn(), + resolveCommand: vi.fn(async () => null), + scriptTerminal: { kind: "direct-command", command: "/bin/sh", argsPrefix: ["-lc"] }, + files: { + subscribe: async (input) => { + subscriptionInput = input; + return { unsubscribe: async () => undefined }; + }, + }, + } as unknown as BoundWorkspaceRuntime; + const coordinator = createGitCommonObservationCoordinator(); + coordinator.bind(runtime, "workspace-1", {} as WorkspaceRuntimeDriver); + + const subscription = await observeWorkspaceGit(runtime, () => undefined); + + expect(subscriptionInput).toEqual({ paths: ["."], recursive: true, ignoredPaths: [] }); + expect(runtime.run).not.toHaveBeenCalled(); + await subscription.unsubscribe(); +}); + +test("closing Git observation waits for and releases an in-flight acquisition", async () => { + const physicalUnsubscribe = vi.fn(async () => {}); + const physical = createDeferred<{ unsubscribe(): Promise }>(); + const workingTreeUnsubscribe = vi.fn(async () => {}); + const runtime = { + files: { + subscribe: vi.fn(async () => ({ unsubscribe: workingTreeUnsubscribe })), + }, + } as unknown as BoundWorkspaceRuntime; + const driver = { + observeGit: vi.fn(() => physical.promise), + } as unknown as WorkspaceRuntimeDriver; + const coordinator = createGitCommonObservationCoordinator(); + coordinator.bind(runtime, "workspace-1", driver); + + const observation = observeWorkspaceGit(runtime, () => undefined); + await vi.waitFor(() => expect(driver.observeGit).toHaveBeenCalledTimes(1)); + const closing = coordinator.close(); + expect(coordinator.close()).toBe(closing); + expect( + await Promise.race([ + closing.then(() => "closed" as const), + Promise.resolve("pending" as const), + ]), + ).toBe("pending"); + + physical.resolve({ unsubscribe: physicalUnsubscribe }); + await expect(observation).rejects.toThrow("Git common observation coordinator is closed"); + await closing; + expect(physicalUnsubscribe).toHaveBeenCalledTimes(1); + expect(workingTreeUnsubscribe).toHaveBeenCalledTimes(1); + + await expect(observeWorkspaceGit(runtime, () => undefined)).rejects.toThrow( + "Git common observation coordinator is closed", + ); + expect(driver.observeGit).toHaveBeenCalledTimes(1); +}); diff --git a/packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts b/packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts new file mode 100644 index 0000000000..45f9e43be0 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts @@ -0,0 +1,206 @@ +import type { BoundWorkspaceRuntime } from "../../index.js"; +import type { WorkspaceRuntimeDriver } from "../../drivers/index.js"; +import type { WorkspaceFilesSubscription } from "@getpaseo/workspace-helper"; +import { registerGitCommonObservationCapability } from "./capability.js"; + +interface GitObservation { + workspaceId: string; + runtime: BoundWorkspaceRuntime; + driver: WorkspaceRuntimeDriver; + listener: () => void; + physical: { unsubscribe(): Promise } | null; +} + +type PhysicalGitObservation = NonNullable; + +export interface ObservationRebindTransaction { + commit(): Promise; + rollback(): Promise; +} + +export interface GitCommonObservationCoordinator { + bind(runtime: BoundWorkspaceRuntime, workspaceId: string, driver: WorkspaceRuntimeDriver): void; + close(): Promise; + pause(workspaceId: string): Promise; + stageResume(workspaceId: string): Promise; + destroy(workspaceId: string): Promise; +} + +/** Git invalidation is workspace-bound. Runtime-private observers never disclose placement. */ +export function createGitCommonObservationCoordinator(): GitCommonObservationCoordinator { + const observations = new Set(); + const acquisitions = new Set>(); + const resumeStages = new Set>(); + let closePromise: Promise | null = null; + let closed = false; + + return { + bind(runtime, workspaceId, driver) { + assertOpen(); + registerGitCommonObservationCapability(runtime, { + observe: async (listener) => { + assertOpen(); + const observation: GitObservation = { + workspaceId, + runtime, + driver, + listener, + physical: null, + }; + observation.physical = await acquire(observation); + observations.add(observation); + let active = true; + return { + async unsubscribe() { + if (!active) return; + active = false; + observations.delete(observation); + await stop(observation); + }, + }; + }, + }); + }, + close() { + if (closePromise) return closePromise; + closed = true; + closePromise = (async () => { + await Promise.allSettled(acquisitions); + await Promise.allSettled([...resumeStages].map((stage) => disposeStage(stage))); + const selected = [...observations]; + observations.clear(); + await Promise.allSettled(selected.map((observation) => stop(observation))); + })(); + return closePromise; + }, + async pause(workspaceId) { + for (const observation of owned(workspaceId)) { + await stop(observation); + } + }, + async stageResume(workspaceId) { + assertOpen(); + const staged = new Map(); + resumeStages.add(staged); + try { + for (const observation of owned(workspaceId)) { + if (!observation.physical) staged.set(observation, await acquire(observation)); + } + } catch (error) { + await disposeStage(staged); + throw error; + } + let finished = false; + return { + async commit() { + if (finished) return; + if (closed) { + finished = true; + await disposeStage(staged); + throw new Error("Git common observation coordinator is closed"); + } + finished = true; + resumeStages.delete(staged); + for (const [observation, physical] of staged) observation.physical = physical; + staged.clear(); + }, + async rollback() { + if (finished) return; + finished = true; + await disposeStage(staged); + }, + }; + }, + async destroy(workspaceId) { + const selected = owned(workspaceId); + for (const observation of selected) observations.delete(observation); + await Promise.allSettled(selected.map((observation) => stop(observation))); + }, + }; + + function owned(workspaceId: string): GitObservation[] { + return [...observations].filter((observation) => observation.workspaceId === workspaceId); + } + + function assertOpen(): void { + if (closed) throw new Error("Git common observation coordinator is closed"); + } + + function acquire(observation: GitObservation): Promise { + assertOpen(); + const acquisition = (async () => { + const physical = await start(observation); + if (closed) { + await physical.unsubscribe(); + throw new Error("Git common observation coordinator is closed"); + } + return physical; + })(); + acquisitions.add(acquisition); + void acquisition.then( + () => acquisitions.delete(acquisition), + () => acquisitions.delete(acquisition), + ); + return acquisition; + } + + async function stop(observation: GitObservation): Promise { + const physical = observation.physical; + observation.physical = null; + await physical?.unsubscribe(); + } + + async function disposeStage(stage: Map): Promise { + resumeStages.delete(stage); + const physical = [...stage.values()]; + stage.clear(); + await Promise.allSettled(physical.map((item) => item.unsubscribe())); + } + + async function start(observation: GitObservation): Promise { + if (observation.driver.observeGit) { + return observation.driver.observeGit(observation.workspaceId, observation.listener); + } + const ignoredPaths = await readGitIgnoredPaths(observation.runtime); + const subscription: WorkspaceFilesSubscription = await observation.runtime.files.subscribe( + { paths: ["."], recursive: true, ignoredPaths }, + (event) => { + if (event.type !== "error") observation.listener(); + }, + ); + return subscription; + } +} + +async function readGitIgnoredPaths(runtime: BoundWorkspaceRuntime): Promise { + const git = await runtime.resolveCommand("git"); + if (!git) return []; + const process = await runtime.run({ + argv: [git, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], + env: { GIT_OPTIONAL_LOCKS: "0" }, + purpose: { kind: "git" }, + }); + process.stdin.end(); + const [stdout, , exit] = await Promise.all([ + collect(process.stdout), + collect(process.stderr), + process.exited, + ]); + if (exit.code !== 0 || exit.signal !== null) return []; + return [ + ...new Set( + stdout + .split("\0") + .map((entry) => entry.replace(/\/+$/, "")) + .filter(Boolean), + ), + ]; +} + +async function collect(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/packages/server/src/server/workspace-runtime/index.ts b/packages/server/src/server/workspace-runtime/index.ts new file mode 100644 index 0000000000..1ba2b89098 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/index.ts @@ -0,0 +1,257 @@ +import type { Readable, Writable } from "node:stream"; +import { createHash } from "node:crypto"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { WorkspaceFiles } from "@getpaseo/workspace-helper"; + +import { createCommandRuntimeAdapter } from "./command/index.js"; +import { createHostGitObservationOwner } from "./internal/host-git-observation.js"; +import { createLocalRuntime } from "./internal/local-runtime.js"; +import { createService } from "./internal/service.js"; +import { createWorktreeRuntime } from "./internal/worktree-runtime.js"; + +export type WorkspaceProjectSource = + | { kind: "host-directory"; path: string } + | { kind: "git"; url: string; revision: string; subdirectory?: string }; + +export type WorkspacePlacement = + | { kind: "existing"; relativeCwd?: string } + | { + kind: "branch"; + branchName: string; + baseRef: string; + relativeCwd?: string; + worktreeSlug?: string; + } + | { kind: "checkout"; ref: string; relativeCwd?: string; worktreeSlug?: string } + | ResolvedWorktreePlacement; + +export type ResolvedWorktreeSource = + | { kind: "branch-off"; baseBranch: string; branchName: string } + | { kind: "checkout-branch"; branchName: string } + | { + kind: "checkout-change-request" | "checkout-github-pr"; + forge?: string; + changeRequestNumber?: number; + githubPrNumber?: number; + headRef: string; + headRepositoryOwner?: string; + baseRefName: string; + checkoutRefs?: readonly { + remoteName?: string; + remoteRef: string; + }[]; + localBranchName?: string; + pushRemoteUrl?: string; + trackOriginHead?: boolean; + }; + +export interface ResolvedWorktreePlacement { + kind: "resolved-worktree"; + source: ResolvedWorktreeSource; + worktreeSlug: string; + relativeCwd?: string; +} + +export type WorkspaceProcessPurpose = + | { kind: "agent"; agentId: string; provider: string } + | { kind: "terminal"; terminalId: string } + | { kind: "git" } + | { kind: "provider-probe"; provider: string } + | { kind: "workspace-helper" } + | { kind: "workspace-script"; script: string } + | { kind: "setup" } + | { kind: "archive" }; + +export interface WorkspaceSetupCommand { + cwd?: string; + argv: readonly [string, ...string[]]; + env: Readonly>; +} + +export interface CreateWorkspaceInput { + workspaceId: string; + runtimeId: string; + project: { id: string; source: WorkspaceProjectSource }; + placement: WorkspacePlacement; + purpose?: "provider-probe"; + markFirstAgentBranchAutoName?: boolean; + seedPaseoConfigFrom?: string; +} + +export interface WorkspaceProcessInput extends WorkspaceSetupCommand { + workspaceId: string; + purpose: WorkspaceProcessPurpose; +} + +export interface WorkspaceTerminalInput extends WorkspaceProcessInput { + rows: number; + cols: number; + term?: string; +} + +export interface WorkspaceProcessExit { + code: number | null; + signal: NodeJS.Signals | null; +} + +export interface WorkspaceProcess { + readonly stdin: Writable; + readonly stdout: Readable; + readonly stderr: Readable; + readonly exited: Promise; + kill(signal?: NodeJS.Signals): void; +} + +export type WorkspaceScriptTerminal = + | { readonly kind: "persistent-shell" } + | { + readonly kind: "direct-command"; + readonly command: string; + readonly argsPrefix: readonly string[]; + }; + +export interface WorkspaceTerminal { + onData(listener: (data: string) => void): () => void; + write(data: string): void; + resize(cols: number, rows: number): void; + readonly exited: Promise; + kill(signal?: NodeJS.Signals): void; +} + +export interface BoundWorkspaceRuntime { + run(input: Omit): Promise; + resolveCommand(command: string): Promise; + readonly scriptTerminal: WorkspaceScriptTerminal; + readonly provider: WorkspaceRuntimeProviderCapability; + readonly files: WorkspaceFiles; +} + +export interface WorkspaceRuntimeProviderCapability { + readonly environment: "inherit-sanitized-host" | "isolated"; + readonly sharedHostProviders: ReadonlySet; +} + +export interface WorkspaceRuntimeService { + listRuntimes(): readonly WorkspaceRuntimeCatalogEntry[]; + reconcile(): Promise; + close(): Promise; + create(input: CreateWorkspaceInput): Promise; + run(input: WorkspaceProcessInput): Promise; + openTerminal(input: WorkspaceTerminalInput): Promise; + bind(workspaceId: string): Promise; + files(workspaceId: string): WorkspaceFiles; + inspect(workspaceId: string): Promise; + requireHostVisiblePath(workspaceId: string): Promise; + pause(workspaceId: string): Promise; + resume(workspaceId: string): Promise; + archive(workspaceId: string, options?: { releaseBacking?: boolean }): Promise; + restore(workspaceId: string): Promise; + destroy(workspaceId: string): Promise; +} + +export interface WorkspaceRuntimeCatalogEntry { + runtimeId: string; + builtin: boolean; + label?: string; + requiresGitProject: boolean; +} + +export interface WorkspaceRuntimePlacement { + workspaceId: string; + runtimeId: string; + cwd: string; + hostVisiblePath?: string; + materializedFreshContent: boolean; +} + +export type WorkspaceRuntimeInspection = + | { status: "missing" | "error" } + | ({ status: "paused" | "ready" } & Omit< + WorkspaceRuntimePlacement, + "workspaceId" | "runtimeId" | "materializedFreshContent" + >); + +export interface WorkspaceRuntimeRecordStore { + resolveRuntimeId(workspaceId: string): Promise; + persistRuntimeId( + workspaceId: string, + runtimeId: string, + placement: { cwd: string; hostVisiblePath?: string }, + ): Promise; + archiveWorkspaceRecord?(workspaceId: string): Promise; + restoreWorkspaceRecord?(workspaceId: string): Promise; + beginWorkspaceDeletion?(workspaceId: string): Promise; + removeWorkspaceRecord?(workspaceId: string): Promise; + listRuntimeRecords?(): Promise< + readonly { workspaceId: string; runtimeId: string; archived: boolean; deleting?: boolean }[] + >; +} + +export interface ExternalWorkspaceRuntime { + type: "command"; + label?: string; + command: readonly [string, ...string[]]; + options?: Readonly>; +} +export type WorkspaceRuntimeConfig = ExternalWorkspaceRuntime; +export type WorkspaceRuntimeJsonValue = + | string + | number + | boolean + | null + | readonly WorkspaceRuntimeJsonValue[] + | { readonly [key: string]: WorkspaceRuntimeJsonValue }; + +export interface WorkspaceRuntimeOptions extends WorkspaceRuntimeRecordStore { + paseoHome: string; + worktreesRoot?: string; + externalRuntimes?: Readonly>; + commandResolutionBase?: string; +} + +export function createWorkspaceRuntimeService( + options: WorkspaceRuntimeOptions, +): WorkspaceRuntimeService { + const hostGitObservations = createHostGitObservationOwner(); + const configuredRuntimes = options.externalRuntimes ?? {}; + const externalEntries = Object.entries(configuredRuntimes); + const runtimeInstanceId = createHash("sha256").update(resolve(options.paseoHome)).digest("hex"); + const externalDrivers = externalEntries.map(([runtimeId, config]) => { + if (runtimeId === "local" || runtimeId === "worktree") { + throw new Error(`Workspace runtime id is reserved: ${runtimeId}`); + } + return createCommandRuntimeAdapter( + runtimeId, + config, + runtimeInstanceId, + options.commandResolutionBase ?? fileURLToPath(new URL(".", import.meta.url)), + options.paseoHome, + ); + }); + const drivers = [ + createLocalRuntime(options.paseoHome, hostGitObservations), + createWorktreeRuntime({ + paseoHome: options.paseoHome, + worktreesRoot: options.worktreesRoot, + hostGitObservations, + }), + ...externalDrivers, + ]; + const catalogMetadata = new Map([ + ["local", { builtin: true }], + ["worktree", { builtin: true }], + ...externalEntries.map( + ([runtimeId, config]) => + [ + runtimeId, + { + builtin: false, + ...(config.label ? { label: config.label } : {}), + }, + ] as const, + ), + ]); + return createService(drivers, options, catalogMetadata); +} diff --git a/packages/server/src/server/workspace-runtime/internal/host-git-observation.posix.test.ts b/packages/server/src/server/workspace-runtime/internal/host-git-observation.posix.test.ts new file mode 100644 index 0000000000..cceefc37a8 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/internal/host-git-observation.posix.test.ts @@ -0,0 +1,178 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { + type FileObserver, + type FileObserverCallback, + type FileObserverDiagnostics, + type FileObserverOptions, +} from "../../file-observer/index.js"; +import { getGitCommonDir } from "../../../utils/worktree.js"; +import { createHostGitObservationOwner } from "./host-git-observation.js"; + +describe.runIf(process.platform !== "win32")("host Git observation on POSIX", () => { + test("sibling host worktrees share one physical common-Git observation", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-host-git-observation-")); + try { + const source = await repository(path.join(root, "source")); + const sibling = path.join(root, "sibling"); + git(source, ["branch", "sibling"]); + git(source, ["worktree", "add", sibling, "sibling"]); + const unrelated = await repository(path.join(root, "unrelated")); + const observer = new InstrumentedFileObserver(); + const owner = createHostGitObservationOwner(observer); + let sourceChanges = 0; + let siblingChanges = 0; + let unrelatedChanges = 0; + + const [sourceSubscription, siblingSubscription] = await Promise.all([ + owner.observe(source, () => { + sourceChanges += 1; + }), + owner.observe(sibling, () => { + siblingChanges += 1; + }), + ]); + expect(owner.getDiagnostics().activeObservationCount).toBe(1); + + const unrelatedSubscription = await owner.observe(unrelated, () => { + unrelatedChanges += 1; + }); + expect(owner.getDiagnostics().activeObservationCount).toBe(2); + + observer.emit(await realpath(await getGitCommonDir(source))); + expect(sourceChanges).toBe(1); + expect(siblingChanges).toBe(1); + expect(unrelatedChanges).toBe(0); + + await sourceSubscription.unsubscribe(); + expect(owner.getDiagnostics().activeObservationCount).toBe(2); + await siblingSubscription.unsubscribe(); + expect(owner.getDiagnostics().activeObservationCount).toBe(1); + await unrelatedSubscription.unsubscribe(); + expect(owner.getDiagnostics().activeObservationCount).toBe(0); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("a failed physical common watcher rebinds once without changing logical ownership", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-host-git-rebind-")); + try { + const source = await repository(path.join(root, "source")); + const sibling = path.join(root, "sibling"); + git(source, ["branch", "sibling"]); + git(source, ["worktree", "add", sibling, "sibling"]); + const observer = new InstrumentedFileObserver(); + const owner = createHostGitObservationOwner(observer); + let notifications = 0; + const subscriptions = await Promise.all([ + owner.observe(source, () => { + notifications += 1; + }), + owner.observe(sibling, () => { + notifications += 1; + }), + ]); + expect(observer.subscribeCount).toBe(1); + expect(observer.getDiagnostics().activeObservationCount).toBe(1); + + observer.failActive(new Error("physical watcher failed")); + const barrier = await owner.observe(sibling, () => undefined); + expect(notifications).toBe(2); + expect(observer.subscribeCount).toBe(2); + expect(observer.getDiagnostics().activeObservationCount).toBe(1); + + await Promise.all([ + ...subscriptions.map((subscription) => subscription.unsubscribe()), + barrier.unsubscribe(), + ]); + expect(observer.getDiagnostics().activeObservationCount).toBe(0); + await observer.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + +class InstrumentedFileObserver implements FileObserver { + readonly callbacks = new Map(); + subscribeCount = 0; + + async subscribe( + directory: string, + callback: FileObserverCallback, + _options?: FileObserverOptions, + ) { + this.subscribeCount += 1; + this.callbacks.set(callback, directory); + let active = true; + return { + updateIgnore: async (_paths: string[]) => {}, + unsubscribe: async () => { + if (!active) return; + active = false; + this.callbacks.delete(callback); + }, + }; + } + + failActive(error: Error): void { + for (const callback of this.callbacks.keys()) callback(error, []); + } + + emit(directory: string): void { + for (const [callback, observedDirectory] of this.callbacks) { + if (observedDirectory === directory) + callback(null, [{ path: "refs/heads/main", type: "update" }]); + } + } + + getDiagnostics(): FileObserverDiagnostics { + return { + activeObservationCount: this.callbacks.size, + nativeHandleCount: 0, + nativeTrackedFileCount: 0, + pendingEventCount: 0, + pendingReconciliationWorkCount: 0, + reconciliationInFlightCount: 0, + reconciliationCount: 0, + scopedReconciliationCount: 0, + fullReconciliationCount: 0, + reconciliationFailureCount: 0, + observerFailureCount: 0, + directoryLimitFailureCount: 0, + nativeEventCount: 0, + nativeChangeEventCount: 0, + nativeRenameEventCount: 0, + nativePathlessEventCount: 0, + nativeClassificationCount: 0, + nativeShallowScanCount: 0, + lastReconciliationDurationMs: 0, + maxReconciliationDurationMs: 0, + }; + } + + async close(): Promise { + this.callbacks.clear(); + } +} + +async function repository(directory: string): Promise { + await mkdir(directory); + git(directory, ["init", "-b", "main"]); + git(directory, ["config", "user.email", "test@getpaseo.local"]); + git(directory, ["config", "user.name", "Paseo Test"]); + await writeFile(path.join(directory, "tracked.txt"), "tracked\n"); + git(directory, ["add", "."]); + git(directory, ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"]); + return directory; +} + +function git(cwd: string, argv: string[]): void { + execFileSync("git", argv, { cwd, stdio: "pipe" }); +} diff --git a/packages/server/src/server/workspace-runtime/internal/host-git-observation.ts b/packages/server/src/server/workspace-runtime/internal/host-git-observation.ts new file mode 100644 index 0000000000..85e0fea89d --- /dev/null +++ b/packages/server/src/server/workspace-runtime/internal/host-git-observation.ts @@ -0,0 +1,110 @@ +import { + createFileObserver, + type FileObserver, + type FileObserverDiagnostics, + type FileObserverSubscription, +} from "../../file-observer/index.js"; +import { realpath } from "node:fs/promises"; +import { getGitCommonDir } from "../../../utils/worktree.js"; + +interface CommonObservation { + listeners: Set<() => void>; + subscription: FileObserverSubscription | null; +} + +export interface HostGitObservationOwner { + observe(root: string, listener: () => void): Promise<{ unsubscribe(): Promise }>; + getDiagnostics(): FileObserverDiagnostics; +} + +/** One private physical common-Git watcher per host common directory. */ +export function createHostGitObservationOwner( + observer: FileObserver = createFileObserver(), +): HostGitObservationOwner { + const observations = new Map(); + const tails = new Map>(); + + return { + async observe(root, listener) { + const commonDirectory = await realpath(await getGitCommonDir(root)); + await sequence(commonDirectory, async () => { + let observation = observations.get(commonDirectory); + if (!observation) { + const listeners = new Set<() => void>(); + observation = { listeners, subscription: null }; + observations.set(commonDirectory, observation); + } + observation.subscription ??= await subscribe(commonDirectory, observation); + observation.listeners.add(listener); + }); + let active = true; + return { + async unsubscribe() { + if (!active) return; + active = false; + await sequence(commonDirectory, async () => { + const observation = observations.get(commonDirectory); + if (!observation) return; + observation.listeners.delete(listener); + if (observation.listeners.size > 0) return; + observations.delete(commonDirectory); + await observation.subscription?.unsubscribe(); + observation.subscription = null; + }); + }, + }; + }, + getDiagnostics() { + return observer.getDiagnostics(); + }, + }; + + async function sequence(key: string, operation: () => Promise): Promise { + const previous = tails.get(key) ?? Promise.resolve(); + const current = previous.catch(() => undefined).then(operation); + tails.set(key, current); + try { + await current; + } finally { + if (tails.get(key) === current) tails.delete(key); + } + } + + async function subscribe( + commonDirectory: string, + observation: CommonObservation, + ): Promise { + let subscription!: FileObserverSubscription; + subscription = await observer.subscribe(commonDirectory, (error, events) => { + if (error || events.length > 0) { + for (const notify of observation.listeners) notify(); + } + if (error) rebindAfterFailure(commonDirectory, observation, subscription); + }); + return subscription; + } + + function rebindAfterFailure( + commonDirectory: string, + observation: CommonObservation, + failedSubscription: FileObserverSubscription, + ): void { + void sequence(commonDirectory, async () => { + if ( + observations.get(commonDirectory) !== observation || + observation.subscription !== failedSubscription + ) { + return; + } + observation.subscription = null; + await failedSubscription.unsubscribe(); + if (observation.listeners.size === 0) { + observations.delete(commonDirectory); + return; + } + observation.subscription = await subscribe(commonDirectory, observation); + }).catch(() => { + for (const notify of observation.listeners) notify(); + }); + } +} diff --git a/packages/server/src/server/workspace-runtime/internal/host-helper.test.ts b/packages/server/src/server/workspace-runtime/internal/host-helper.test.ts new file mode 100644 index 0000000000..40c1fc4f1c --- /dev/null +++ b/packages/server/src/server/workspace-runtime/internal/host-helper.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "vitest"; + +import { hostWorkspaceHelper } from "./host-helper.js"; + +describe("hostWorkspaceHelper", () => { + test("uses the external host environment plus Electron Node mode", () => { + expect(hostWorkspaceHelper.env.PATH).toBe(process.env.PATH); + expect(hostWorkspaceHelper.env.ELECTRON_RUN_AS_NODE).toBe("1"); + expect(hostWorkspaceHelper.env.PASEO_DESKTOP_MANAGED).toBeUndefined(); + expect(hostWorkspaceHelper.env.PASEO_SUPERVISED).toBeUndefined(); + }); +}); diff --git a/packages/server/src/server/workspace-runtime/internal/host-helper.ts b/packages/server/src/server/workspace-runtime/internal/host-helper.ts new file mode 100644 index 0000000000..04d8ea8428 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/internal/host-helper.ts @@ -0,0 +1,9 @@ +import { buildSelfNodeCommand } from "../../paseo-env.js"; +import { workspaceHelperExecutable } from "@getpaseo/workspace-helper"; + +const launch = buildSelfNodeCommand([workspaceHelperExecutable]); + +export const hostWorkspaceHelper = { + command: [launch.command, ...launch.args] as readonly [string, ...string[]], + env: launch.env, +}; diff --git a/packages/server/src/server/workspace-runtime/internal/host-process.ts b/packages/server/src/server/workspace-runtime/internal/host-process.ts new file mode 100644 index 0000000000..ac74a2eced --- /dev/null +++ b/packages/server/src/server/workspace-runtime/internal/host-process.ts @@ -0,0 +1,130 @@ +import { spawn } from "node:child_process"; +import { realpath } from "node:fs/promises"; +import { constants } from "node:os"; +import path from "node:path"; +import * as pty from "node-pty"; + +import type { + WorkspaceDriverSpawnInput, + WorkspacePipeProcess, + WorkspacePtyProcess, + WorkspaceProcessExit, +} from "../drivers/index.js"; + +export async function spawnHostProcess( + root: string, + input: WorkspaceDriverSpawnInput, +): Promise { + const cwd = await resolveRuntimeCwd(root, input.cwd); + const child = spawn(input.argv[0], input.argv.slice(1), { + cwd, + env: { ...input.env }, + shell: false, + detached: process.platform !== "win32", + stdio: ["pipe", "pipe", "pipe"], + }); + const exited = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => { + resolve({ code, signal: signal as NodeJS.Signals | null }); + }); + }); + + return { + kind: "pipes", + stdin: child.stdin, + stdout: child.stdout, + stderr: child.stderr, + exited, + kill(signal = "SIGTERM") { + if (child.exitCode !== null || child.signalCode !== null) return; + if (process.platform !== "win32" && child.pid) { + try { + process.kill(-child.pid, signal); + return; + } catch { + // The process can exit between the state check and the signal. + } + } + child.kill(signal); + }, + }; +} + +export async function spawnHostPty( + root: string, + input: WorkspaceDriverSpawnInput, +): Promise { + if (input.stdio.kind !== "pty") throw new Error("PTY spawn requires PTY stdio"); + const cwd = await resolveRuntimeCwd(root, input.cwd); + const process = pty.spawn(input.argv[0], input.argv.slice(1), { + cwd, + env: { ...input.env }, + name: input.stdio.term ?? "xterm-256color", + cols: input.stdio.cols, + rows: input.stdio.rows, + }); + const exited = new Promise((resolve) => { + process.onExit(({ exitCode, signal }) => { + const signalNumber = signal ?? 0; + resolve({ + code: signalNumber === 0 ? exitCode : null, + signal: signalName(signalNumber), + }); + }); + }); + const listeners = new Set<(data: string) => void>(); + let pendingData = ""; + process.onData((data) => { + if (listeners.size === 0) { + pendingData += data; + return; + } + for (const listener of listeners) listener(data); + }); + + return { + kind: "pty", + onData(listener) { + listeners.add(listener); + if (pendingData) { + const data = pendingData; + pendingData = ""; + listener(data); + } + return () => listeners.delete(listener); + }, + write(data) { + process.write(data); + }, + resize(cols, rows) { + process.resize(cols, rows); + }, + exited, + kill(signal) { + process.kill(signal); + }, + }; +} + +function signalName(signal: number): NodeJS.Signals | null { + if (signal === 0) return null; + for (const [name, number] of Object.entries(constants.signals)) { + if (number !== undefined && number === signal && isNodeSignal(name)) return name; + } + return null; +} + +function isNodeSignal(value: string): value is NodeJS.Signals { + return value in constants.signals; +} + +export async function resolveRuntimeCwd(root: string, relativeCwd?: string): Promise { + const normalizedRoot = await realpath(root); + const cwd = await realpath(path.resolve(normalizedRoot, relativeCwd ?? ".")); + const relative = path.relative(normalizedRoot, cwd); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`Workspace cwd escapes its runtime root: ${relativeCwd}`); + } + return cwd; +} diff --git a/packages/server/src/server/workspace-runtime/internal/local-runtime.ts b/packages/server/src/server/workspace-runtime/internal/local-runtime.ts new file mode 100644 index 0000000000..d1838ad7b4 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/internal/local-runtime.ts @@ -0,0 +1,136 @@ +import { stat } from "node:fs/promises"; +import path from "node:path"; + +import type { + WorkspaceDriverCreateInput, + WorkspaceDriverInspection, + WorkspaceDriverSpawnInput, + WorkspaceDriverState, + WorkspaceRuntimeDriver, +} from "../drivers/index.js"; +import { resolveRuntimeCwd, spawnHostProcess, spawnHostPty } from "./host-process.js"; +import { hostWorkspaceHelper } from "./host-helper.js"; +import type { HostGitObservationOwner } from "./host-git-observation.js"; +import { createRuntimeStateStore } from "./runtime-state.js"; + +interface LocalRuntimeState { + workspaceId: string; + root: string; + lifecycle: "ready" | "paused"; + compatibilityCwd?: string; +} + +export function createLocalRuntime( + paseoHome: string, + hostGitObservations: HostGitObservationOwner, +): WorkspaceRuntimeDriver { + const states = createRuntimeStateStore(paseoHome, "local", isLocalRuntimeState); + + async function inspect(workspaceId: string): Promise { + const state = (await states.read(workspaceId)) as LocalRuntimeState | null; + if (!state) return { status: "missing" }; + try { + if (!(await stat(state.root)).isDirectory()) return { status: "missing" }; + return { + status: state.lifecycle, + state: publicState(state), + placement: hostPlacement(state.compatibilityCwd ?? state.root), + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" }; + return { status: "error", message: String(error) }; + } + } + + async function requireReady(workspaceId: string): Promise { + const state = await states.read(workspaceId); + if (!state || state.lifecycle !== "ready") { + throw new Error( + `Workspace runtime local is ${state?.lifecycle ?? "missing"}: ${workspaceId}`, + ); + } + return state; + } + + return { + id: "local", + requiresGitProject: false, + workspaceHelper: hostWorkspaceHelper, + scriptTerminal: { kind: "persistent-shell" }, + provider: { + environment: "inherit-sanitized-host", + sharedHostProviders: new Set(["opencode"]), + }, + async create(input: WorkspaceDriverCreateInput) { + const existing = await inspect(input.workspaceId); + if (existing.status === "ready" || existing.status === "paused") { + return { ...existing, materializedFreshContent: false }; + } + if (input.project.source.kind !== "host-directory" || input.placement.kind !== "existing") { + throw new Error("The local runtime adopts an existing host directory"); + } + const sourceCwd = path.resolve(input.project.source.path); + const compatibilityCwd = path.resolve(sourceCwd, input.placement.relativeCwd ?? "."); + const root = await resolveRuntimeCwd(sourceCwd, input.placement.relativeCwd); + if (!(await stat(root)).isDirectory()) throw new Error(`Directory not found: ${root}`); + const state: LocalRuntimeState = { + workspaceId: input.workspaceId, + root, + lifecycle: "ready", + compatibilityCwd, + }; + await states.write(state); + return { + state: publicState(state), + placement: hostPlacement(compatibilityCwd), + materializedFreshContent: false, + }; + }, + inspect, + async spawn(input: WorkspaceDriverSpawnInput) { + const root = (await requireReady(input.workspaceId)).root; + return input.stdio.kind === "pty" ? spawnHostPty(root, input) : spawnHostProcess(root, input); + }, + async observeGit(workspaceId, listener) { + return hostGitObservations.observe((await requireReady(workspaceId)).root, listener); + }, + async pause(workspaceId) { + const state = await states.read(workspaceId); + if (state?.lifecycle === "paused") return; + if (!state) throw new Error(`Workspace runtime local is missing: ${workspaceId}`); + await states.write({ ...state, lifecycle: "paused" }); + }, + async resume(workspaceId) { + const current = await states.read(workspaceId); + if (!current) throw new Error(`Workspace runtime local is missing: ${workspaceId}`); + const state = { ...current, lifecycle: "ready" as const }; + await states.write(state); + return { + state: publicState(state), + placement: hostPlacement(state.compatibilityCwd ?? state.root), + }; + }, + async destroy(workspaceId) { + await states.remove(workspaceId); + }, + }; +} + +function publicState(state: LocalRuntimeState): WorkspaceDriverState { + return { workspaceId: state.workspaceId, lifecycle: state.lifecycle }; +} + +function isLocalRuntimeState(value: unknown, workspaceId: string): value is LocalRuntimeState { + if (!value || typeof value !== "object") return false; + const state = value as Partial; + return ( + state.workspaceId === workspaceId && + typeof state.root === "string" && + (state.lifecycle === "ready" || state.lifecycle === "paused") && + (state.compatibilityCwd === undefined || typeof state.compatibilityCwd === "string") + ); +} + +function hostPlacement(root: string) { + return { cwd: root, hostVisiblePath: root }; +} diff --git a/packages/server/src/server/workspace-runtime/internal/runtime-state.ts b/packages/server/src/server/workspace-runtime/internal/runtime-state.ts new file mode 100644 index 0000000000..dc6856fabb --- /dev/null +++ b/packages/server/src/server/workspace-runtime/internal/runtime-state.ts @@ -0,0 +1,35 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export function createRuntimeStateStore( + paseoHome: string, + runtimeId: string, + validate: (value: unknown, workspaceId: string) => value is T, +) { + const directory = path.join(paseoHome, "workspace-runtimes", runtimeId); + const fileFor = (workspaceId: string) => + path.join(directory, `${createHash("sha256").update(workspaceId).digest("hex")}.json`); + return { + async read(workspaceId: string): Promise { + try { + const parsed = JSON.parse(await readFile(fileFor(workspaceId), "utf8")) as unknown; + if (!validate(parsed, workspaceId)) return null; + return parsed; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + }, + async write(state: T): Promise { + await mkdir(directory, { recursive: true }); + const target = fileFor(state.workspaceId); + const temporary = `${target}.${process.pid}.tmp`; + await writeFile(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600 }); + await rename(temporary, target); + }, + async remove(workspaceId: string): Promise { + await rm(fileFor(workspaceId), { force: true }); + }, + }; +} diff --git a/packages/server/src/server/workspace-runtime/internal/service.ts b/packages/server/src/server/workspace-runtime/internal/service.ts new file mode 100644 index 0000000000..83adfb842d --- /dev/null +++ b/packages/server/src/server/workspace-runtime/internal/service.ts @@ -0,0 +1,750 @@ +import type { + CreateWorkspaceInput, + BoundWorkspaceRuntime, + WorkspaceProcess, + WorkspaceProcessInput, + WorkspaceRuntimeRecordStore, + WorkspaceRuntimeService, + WorkspaceTerminal, + WorkspaceTerminalInput, +} from "../index.js"; +import type { + WorkspaceDriverProcess, + WorkspaceDriverCreateInput, + WorkspaceRuntimeDriver, +} from "../drivers/index.js"; +import type { + WorkspaceFiles, + WorkspaceFilesSubscription, + WorkspaceWatchEvent, +} from "@getpaseo/workspace-helper"; +import { bindWorkspaceHelper, type WorkspaceFilesOwner } from "@getpaseo/workspace-helper"; +import { PaseoConfigSchema } from "@getpaseo/protocol/paseo-config-schema"; +import { + createGitCommonObservationCoordinator, + type ObservationRebindTransaction, +} from "../git-observation/internal/integration.js"; +import { findFreePort } from "../../service-proxy.js"; + +const gracefulStopMilliseconds = 1_000; +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>(); + const workspaceTails = new Map>(); + const fileClients = new Map(); + const boundFiles = new Map(); + const fileSubscriptions = new Map< + string, + Set<{ + input: Parameters[0]; + listener: (event: WorkspaceWatchEvent) => void; + bound: WorkspaceFilesSubscription | null; + }> + >(); + const unavailableFiles = new Map(); + const boundRuntimes = new Map(); + const setupAugmentations = new Map>>(); + const gitCommonObservations = createGitCommonObservationCoordinator(); + + function requireRegistered(runtimeId: string): WorkspaceRuntimeDriver { + const driver = driversById.get(runtimeId); + if (!driver) throw new Error(`Workspace runtime is not registered: ${runtimeId}`); + return driver; + } + + async function resolve(workspaceId: string): Promise { + const runtimeId = await records.resolveRuntimeId(workspaceId); + if (!runtimeId) throw new Error(`Workspace runtime is not selected: ${workspaceId}`); + return requireRegistered(runtimeId); + } + + async function runWithDriver( + driver: WorkspaceRuntimeDriver, + input: WorkspaceProcessInput, + ): Promise { + assertWorkspaceAvailable(input.workspaceId); + const inspection = await driver.inspect(input.workspaceId); + if (inspection.status !== "ready") { + throw new Error(`Workspace runtime is ${inspection.status}: ${input.workspaceId}`); + } + const runtimeProcess = await driver.spawn({ + ...input, + env: { + ...(input.purpose.kind === "setup" ? driver.setupEnvironment?.() : {}), + ...input.env, + ...(input.purpose.kind === "setup" || driver.provider.environment === "isolated" + ? inspection.state.lifecycleEnvironment + : {}), + ...(input.purpose.kind === "setup" ? setupAugmentations.get(input.workspaceId) : {}), + }, + stdio: { kind: "pipes" }, + }); + if (runtimeProcess.kind !== "pipes") { + throw new Error(`Workspace runtime returned PTY mode for a pipe launch: ${driver.id}`); + } + trackProcess(input.workspaceId, runtimeProcess); + return runtimeProcess; + } + + async function openTerminalWithDriver( + driver: WorkspaceRuntimeDriver, + input: WorkspaceTerminalInput, + ): Promise { + assertWorkspaceAvailable(input.workspaceId); + const inspection = await driver.inspect(input.workspaceId); + if (inspection.status !== "ready") { + throw new Error(`Workspace runtime is ${inspection.status}: ${input.workspaceId}`); + } + const runtimeProcess = await driver.spawn({ + ...input, + env: { + ...input.env, + ...(driver.provider.environment === "isolated" + ? inspection.state.lifecycleEnvironment + : {}), + }, + stdio: { kind: "pty", rows: input.rows, cols: input.cols, term: input.term }, + }); + if (runtimeProcess.kind !== "pty") { + throw new Error(`Workspace runtime does not support PTY mode: ${driver.id}`); + } + trackProcess(input.workspaceId, runtimeProcess); + return runtimeProcess; + } + + 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[] = []; + const reconciledDomains = new Set(); + for (const driver of drivers) { + const domainId = driver.reconciliationDomainId ?? driver.id; + if (reconciledDomains.has(domainId)) continue; + reconciledDomains.add(domainId); + try { + await driver.reconcile?.( + runtimeRecords + .filter( + (record) => + (driversById.get(record.runtimeId)?.reconciliationDomainId ?? + record.runtimeId) === domainId, + ) + .map((record) => record.workspaceId), + ); + } catch (error) { + failures.push(error); + } + } + for (const record of runtimeRecords) { + try { + await sequence(record.workspaceId, async () => { + const driver = requireRegistered(record.runtimeId); + if (record.deleting) { + await finishDestroy(record.workspaceId, driver); + return; + } + const inspection = await driver.inspect(record.workspaceId); + if (inspection.status === "missing" || inspection.status === "error") return; + await records.persistRuntimeId( + record.workspaceId, + record.runtimeId, + inspection.placement, + ); + if (record.archived && inspection.status === "ready") { + await pauseWithDriver(record.workspaceId, driver); + } else if (!record.archived && inspection.status === "paused") { + unavailableFiles.set(record.workspaceId, "recovering"); + await driver.resume(record.workspaceId); + const rebind = await stageSubscriptionRebind(record.workspaceId, true); + await rebind.commit(); + unavailableFiles.delete(record.workspaceId); + } + }); + } catch (error) { + failures.push(error); + } + } + if (failures.length > 0) + throw new AggregateError(failures, "Workspace reconciliation failed"); + }, + async close() { + const workspaceIds = new Set([ + ...processesByWorkspaceId.keys(), + ...fileClients.keys(), + ...fileSubscriptions.keys(), + ...boundRuntimes.keys(), + ]); + boundRuntimes.clear(); + await Promise.all( + [...fileSubscriptions.values()].flatMap((subscriptions) => + [...subscriptions].map(async (subscription) => { + await subscription.bound?.unsubscribe(); + subscription.bound = null; + }), + ), + ); + await gitCommonObservations.close(); + await Promise.all( + [...workspaceIds].map((workspaceId) => closeFiles(workspaceId, true, null)), + ); + await Promise.all([...workspaceIds].map((workspaceId) => stopProcesses(workspaceId))); + }, + async create(input) { + return sequence(input.workspaceId, async () => { + const selectedRuntimeId = await records.resolveRuntimeId(input.workspaceId); + if (selectedRuntimeId && selectedRuntimeId !== input.runtimeId) { + throw new Error( + `Workspace runtime is already selected as ${selectedRuntimeId}: ${input.workspaceId}`, + ); + } + const driver = requireRegistered(input.runtimeId); + const before = await driver.inspect(input.workspaceId); + let ownsNewResource = false; + try { + const ready = await driver.create(toDriverCreateInput(input)); + ownsNewResource = before.status === "missing"; + if (ready.state.lifecycle === "ready") { + const helper = bindWorkspaceHelper({ + command: driver.workspaceHelper.command, + launch: (argv) => launchHelper(driver, input.workspaceId, argv), + }); + try { + await helper.verify(); + } finally { + await helper.close(); + } + } + await records.persistRuntimeId(input.workspaceId, input.runtimeId, ready.placement); + if (ready.materializedFreshContent && input.purpose !== "provider-probe") { + setupAugmentations.set(input.workspaceId, { + PASEO_WORKTREE_PORT: String(await findFreePort()), + }); + } + unavailableFiles.delete(input.workspaceId); + return { + workspaceId: input.workspaceId, + runtimeId: input.runtimeId, + ...ready.placement, + materializedFreshContent: ready.materializedFreshContent, + }; + } catch (error) { + if (ownsNewResource) { + try { + await stopProcesses(input.workspaceId); + await driver.destroy(input.workspaceId); + } catch (cleanupError) { + throw new Error(`Workspace creation failed before cleanup: ${String(error)}`, { + cause: cleanupError, + }); + } + } + throw error; + } + }); + }, + async run(input) { + return sequence(input.workspaceId, async () => + runWithDriver(await resolve(input.workspaceId), input), + ); + }, + async openTerminal(input) { + return sequence(input.workspaceId, async () => + openTerminalWithDriver(await resolve(input.workspaceId), input), + ); + }, + async bind(workspaceId) { + assertWorkspaceAvailable(workspaceId); + const driver = await resolve(workspaceId); + const inspection = await driver.inspect(workspaceId); + if (inspection.status !== "ready") { + throw new Error(`Workspace runtime is ${inspection.status}: ${workspaceId}`); + } + const cached = boundRuntimes.get(workspaceId); + if (cached?.runtimeId === driver.id) { + return cached.runtime; + } + const runtime: BoundWorkspaceRuntime = { + run: (input) => runWithDriver(driver, { workspaceId, ...input }), + resolveCommand: async (command) => + (await requireFilesOwner(workspaceId)).resolveCommand(command), + scriptTerminal: driver.scriptTerminal, + provider: driver.provider, + files: bindFiles(workspaceId), + }; + gitCommonObservations.bind(runtime, workspaceId, driver); + boundRuntimes.set(workspaceId, { + runtimeId: driver.id, + runtime, + }); + return runtime; + }, + files(workspaceId) { + let files = boundFiles.get(workspaceId); + if (!files) { + files = bindFiles(workspaceId); + boundFiles.set(workspaceId, files); + } + return files; + }, + async inspect(workspaceId) { + const runtimeId = await records.resolveRuntimeId(workspaceId); + if (!runtimeId) return { status: "missing" }; + const inspection = await requireRegistered(runtimeId).inspect(workspaceId); + if (inspection.status === "ready" || inspection.status === "paused") { + return { status: inspection.status, ...inspection.placement }; + } + return { status: inspection.status }; + }, + async requireHostVisiblePath(workspaceId) { + const runtimeId = await records.resolveRuntimeId(workspaceId); + if (!runtimeId) throw new Error(`Workspace runtime is not selected: ${workspaceId}`); + const inspection = await requireRegistered(runtimeId).inspect(workspaceId); + if (inspection.status !== "ready" && inspection.status !== "paused") { + throw new Error(`Workspace runtime is ${inspection.status}: ${workspaceId}`); + } + if (!inspection.placement.hostVisiblePath) { + throw new Error(`Workspace has no host-visible path: ${workspaceId}`); + } + return inspection.placement.hostVisiblePath; + }, + async pause(workspaceId) { + await sequence(workspaceId, async () => { + await pauseWithDriver(workspaceId, await resolve(workspaceId)); + }); + }, + async resume(workspaceId) { + await sequence(workspaceId, async () => { + unavailableFiles.set(workspaceId, "recovering"); + await (await resolve(workspaceId)).resume(workspaceId); + const rebind = await stageSubscriptionRebind(workspaceId, true); + await rebind.commit(); + unavailableFiles.delete(workspaceId); + }); + }, + async archive(workspaceId, archiveOptions) { + await sequence(workspaceId, async () => { + if (!records.archiveWorkspaceRecord) { + throw new Error("Workspace runtime record store cannot archive workspaces"); + } + const driver = await resolve(workspaceId); + const inspection = await driver.inspect(workspaceId); + if (inspection.status === "ready") await runArchiveHooks(workspaceId, driver); + await pauseWithDriver(workspaceId, driver); + if (archiveOptions?.releaseBacking) { + await driver.releaseBacking?.(workspaceId); + } + await records.archiveWorkspaceRecord(workspaceId); + }); + }, + async restore(workspaceId) { + await sequence(workspaceId, async () => { + if (!records.restoreWorkspaceRecord) { + throw new Error("Workspace runtime record store cannot restore workspaces"); + } + unavailableFiles.set(workspaceId, "recovering"); + await (await resolve(workspaceId)).resume(workspaceId); + const rebind = await stageSubscriptionRebind(workspaceId, true); + try { + await records.restoreWorkspaceRecord(workspaceId); + await rebind.commit(); + unavailableFiles.delete(workspaceId); + } catch (error) { + await rebind.rollback(); + throw error; + } + }); + }, + async destroy(workspaceId) { + await sequence(workspaceId, async () => { + const runtimeId = await records.resolveRuntimeId(workspaceId); + if (!runtimeId) { + unavailableFiles.delete(workspaceId); + setupAugmentations.delete(workspaceId); + return; + } + if (!records.beginWorkspaceDeletion || !records.removeWorkspaceRecord) { + throw new Error("Workspace runtime record store cannot permanently delete workspaces"); + } + const driver = requireRegistered(runtimeId); + await records.beginWorkspaceDeletion(workspaceId); + await finishDestroy(workspaceId, driver); + }); + }, + }; + + async function finishDestroy(workspaceId: string, driver: WorkspaceRuntimeDriver): Promise { + unavailableFiles.set(workspaceId, "destroyed"); + boundRuntimes.delete(workspaceId); + await closeFiles(workspaceId, true); + await gitCommonObservations.destroy(workspaceId); + await stopProcesses(workspaceId); + await driver.destroy(workspaceId); + if (!records.removeWorkspaceRecord) { + throw new Error("Workspace runtime record store cannot permanently delete workspaces"); + } + await records.removeWorkspaceRecord(workspaceId); + unavailableFiles.delete(workspaceId); + setupAugmentations.delete(workspaceId); + } + + async function pauseWithDriver( + workspaceId: string, + driver: WorkspaceRuntimeDriver, + ): Promise { + unavailableFiles.set(workspaceId, "paused"); + boundRuntimes.delete(workspaceId); + await closeFiles(workspaceId); + await gitCommonObservations.pause(workspaceId); + await stopProcesses(workspaceId); + await driver.pause(workspaceId); + } + + async function runArchiveHooks( + workspaceId: string, + driver: WorkspaceRuntimeDriver, + ): Promise { + const config = await readWorkspaceConfig(workspaceId); + if (!config) return; + const inspection = await driver.inspect(workspaceId); + if (inspection.status !== "ready") { + throw new Error(`Workspace runtime is ${inspection.status}: ${workspaceId}`); + } + const env = { + ...lifecycleEnvironment(), + ...inspection.state.lifecycleEnvironment, + }; + for (const command of config.worktree?.teardown ?? []) { + const argv = lifecycleShellCommand(command); + const process = await runWithDriver(driver, { + workspaceId, + argv, + env, + purpose: { kind: "archive" }, + }); + process.stdin.end(); + const [stdout, stderr, exit] = await Promise.all([ + drainText(process.stdout), + drainText(process.stderr), + process.exited, + ]); + if (exit.code !== 0 || exit.signal !== null) { + throw new Error( + `Workspace archive command failed (${exit.code ?? exit.signal}): ${stderr || stdout}`, + ); + } + } + } + + async function readWorkspaceConfig(workspaceId: string) { + const files = await requireFiles(workspaceId); + const stat = await files.stat("paseo.json"); + if (stat.status === "missing") return null; + if (stat.status === "error") throw new Error(stat.error); + const file = await files.read("paseo.json"); + const chunks: Buffer[] = []; + for await (const chunk of file.chunks) chunks.push(Buffer.from(chunk)); + return PaseoConfigSchema.parse(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + } + + function assertWorkspaceAvailable(workspaceId: string): void { + const unavailable = unavailableFiles.get(workspaceId); + if (unavailable) throw new Error(`Workspace runtime is ${unavailable}: ${workspaceId}`); + } + + function bindFiles(workspaceId: string): WorkspaceFiles { + return { + async stat(path) { + return (await requireFiles(workspaceId)).stat(path); + }, + async list(path) { + return (await requireFiles(workspaceId)).list(path); + }, + async read(path) { + return (await requireFiles(workspaceId)).read(path); + }, + async write(input) { + return (await requireFiles(workspaceId)).write(input); + }, + async subscribe(input, listener) { + const logical = { input, listener, bound: null as WorkspaceFilesSubscription | null }; + const subscriptions = fileSubscriptions.get(workspaceId) ?? new Set(); + subscriptions.add(logical); + fileSubscriptions.set(workspaceId, subscriptions); + try { + logical.bound = await (await requireFiles(workspaceId)).subscribe(input, listener); + } catch (error) { + subscriptions.delete(logical); + if (subscriptions.size === 0) fileSubscriptions.delete(workspaceId); + throw error; + } + let active = true; + return { + async unsubscribe() { + if (!active) return; + active = false; + subscriptions.delete(logical); + if (subscriptions.size === 0) fileSubscriptions.delete(workspaceId); + await logical.bound?.unsubscribe(); + logical.bound = null; + }, + }; + }, + }; + } + + async function requireFiles(workspaceId: string): Promise { + return (await requireFilesOwner(workspaceId)).files; + } + + async function requireFilesOwner( + workspaceId: string, + allowUnavailable = false, + ): Promise { + const unavailable = unavailableFiles.get(workspaceId); + if (unavailable && !allowUnavailable) + throw new Error(`Workspace runtime is ${unavailable}: ${workspaceId}`); + const driver = await resolve(workspaceId); + const inspection = await driver.inspect(workspaceId); + if (inspection.status !== "ready") { + throw new Error(`Workspace runtime is ${inspection.status}: ${workspaceId}`); + } + const cached = fileClients.get(workspaceId); + if (cached && cached.runtimeId === driver.id) { + return cached.client; + } + if (cached) await cached.client.close(); + const client = bindWorkspaceHelper({ + command: driver.workspaceHelper.command, + launch: async (argv) => { + return launchHelper(driver, workspaceId, argv); + }, + }); + fileClients.set(workspaceId, { + runtimeId: driver.id, + client, + }); + return client; + } + + async function closeFiles( + workspaceId: string, + forgetSubscriptions = false, + reason: Error | null = new Error("Workspace files client is closed"), + ): Promise { + const cached = fileClients.get(workspaceId); + fileClients.delete(workspaceId); + const subscriptions = fileSubscriptions.get(workspaceId); + if (subscriptions) { + for (const subscription of subscriptions) subscription.bound = null; + if (forgetSubscriptions) fileSubscriptions.delete(workspaceId); + } + if (cached) await cached.client.close(reason ?? undefined); + } + + async function stageSubscriptionRebind( + workspaceId: string, + allowUnavailable = false, + ): Promise { + const files = await stageFileSubscriptionRebind(workspaceId, allowUnavailable); + try { + const git = await gitCommonObservations.stageResume(workspaceId); + return combineRebindTransactions([files, git]); + } catch (error) { + await files.rollback(); + throw error; + } + } + + async function stageFileSubscriptionRebind( + workspaceId: string, + allowUnavailable = false, + ): Promise { + const subscriptions = fileSubscriptions.get(workspaceId); + if (!subscriptions || subscriptions.size === 0) return noOpRebindTransaction(); + const files = (await requireFilesOwner(workspaceId, allowUnavailable)).files; + const staged: Array<{ + logical: typeof subscriptions extends Set ? T : never; + bound: WorkspaceFilesSubscription; + }> = []; + try { + for (const logical of subscriptions) { + staged.push({ + logical, + bound: await files.subscribe(logical.input, logical.listener), + }); + } + } catch (error) { + await Promise.allSettled(staged.map(({ bound }) => bound.unsubscribe())); + throw error; + } + let finished = false; + return { + async commit() { + if (finished) return; + finished = true; + for (const { logical, bound } of staged) logical.bound = bound; + }, + async rollback() { + if (finished) return; + finished = true; + await Promise.allSettled(staged.map(({ bound }) => bound.unsubscribe())); + }, + }; + } + + async function launchHelper( + driver: WorkspaceRuntimeDriver, + workspaceId: string, + argv: readonly [string, ...string[]], + ) { + const runtimeProcess = await driver.spawn({ + workspaceId, + argv, + env: driver.workspaceHelper.env, + purpose: { kind: "workspace-helper" }, + stdio: { kind: "pipes" }, + }); + if (runtimeProcess.kind !== "pipes") { + throw new Error(`Workspace runtime returned PTY mode for its helper: ${driver.id}`); + } + trackProcess(workspaceId, runtimeProcess); + return runtimeProcess; + } + + async function sequence(workspaceId: string, operation: () => Promise): Promise { + const previous = workspaceTails.get(workspaceId) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolveGate) => { + release = resolveGate; + }); + const tail = previous.then(() => gate); + workspaceTails.set(workspaceId, tail); + await previous; + try { + return await operation(); + } finally { + release(); + if (workspaceTails.get(workspaceId) === tail) workspaceTails.delete(workspaceId); + } + } + + function trackProcess(workspaceId: string, runtimeProcess: WorkspaceDriverProcess): void { + const processes = processesByWorkspaceId.get(workspaceId) ?? new Set(); + processes.add(runtimeProcess); + processesByWorkspaceId.set(workspaceId, processes); + void runtimeProcess.exited.then( + () => forgetProcess(workspaceId, runtimeProcess), + () => forgetProcess(workspaceId, runtimeProcess), + ); + } + + function forgetProcess(workspaceId: string, runtimeProcess: WorkspaceDriverProcess): void { + const processes = processesByWorkspaceId.get(workspaceId); + processes?.delete(runtimeProcess); + if (processes?.size === 0) processesByWorkspaceId.delete(workspaceId); + } + + async function stopProcesses(workspaceId: string): Promise { + const processes = [...(processesByWorkspaceId.get(workspaceId) ?? [])]; + for (const runtimeProcess of processes) runtimeProcess.kill("SIGTERM"); + const graceful = await waitForAll(processes, gracefulStopMilliseconds); + if (graceful) return; + for (const runtimeProcess of processes) runtimeProcess.kill("SIGKILL"); + if (!(await waitForAll(processes, forcedStopMilliseconds))) { + throw new Error(`Workspace processes did not stop: ${workspaceId}`); + } + } +} + +function combineRebindTransactions( + transactions: readonly ObservationRebindTransaction[], +): ObservationRebindTransaction { + let finished = false; + return { + async commit() { + if (finished) return; + finished = true; + for (const transaction of transactions) await transaction.commit(); + }, + async rollback() { + if (finished) return; + finished = true; + await Promise.allSettled(transactions.map((transaction) => transaction.rollback())); + }, + }; +} + +function noOpRebindTransaction(): ObservationRebindTransaction { + return { commit: async () => {}, rollback: async () => {} }; +} + +async function drainText(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of stream) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + if (bytes < 64 * 1024) chunks.push(buffer.subarray(0, 64 * 1024 - bytes)); + bytes += buffer.byteLength; + } + return Buffer.concat(chunks).toString("utf8").trim(); +} + +function lifecycleShellCommand(command: string): readonly [string, ...string[]] { + return process.platform === "win32" + ? ["powershell.exe", "-NoProfile", "-Command", command] + : ["/bin/sh", "-c", command]; +} + +function lifecycleEnvironment(): Readonly> { + if (process.platform === "win32") { + return { PATH: process.env.PATH ?? "" }; + } + return { PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin" }; +} + +function toDriverCreateInput(input: CreateWorkspaceInput): WorkspaceDriverCreateInput { + return { + workspaceId: input.workspaceId, + project: { + projectId: input.project.id, + source: input.project.source, + }, + placement: input.placement, + ...(input.purpose ? { purpose: input.purpose } : {}), + ...(input.markFirstAgentBranchAutoName ? { markFirstAgentBranchAutoName: true } : {}), + ...(input.seedPaseoConfigFrom ? { seedPaseoConfigFrom: input.seedPaseoConfigFrom } : {}), + }; +} + +async function waitForAll( + processes: readonly WorkspaceDriverProcess[], + timeoutMilliseconds: number, +): Promise { + if (processes.length === 0) return true; + let timer: ReturnType | undefined; + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMilliseconds); + }); + const exited = Promise.allSettled(processes.map((runtimeProcess) => runtimeProcess.exited)).then( + () => true as const, + ); + const result = await Promise.race([exited, timedOut]); + if (timer) clearTimeout(timer); + return result; +} diff --git a/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts new file mode 100644 index 0000000000..0f410b3083 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts @@ -0,0 +1,417 @@ +import { mkdir, stat } from "node:fs/promises"; +import path from "node:path"; + +import { + createWorktree, + deletePaseoWorktree, + seedPaseoConfigFile, + type WorktreeSource, +} from "../../../utils/worktree.js"; +import type { + WorkspaceDriverCreateInput, + WorkspaceDriverInspection, + WorkspaceDriverSpawnInput, + WorkspaceDriverState, + WorkspaceRuntimeDriver, +} from "../drivers/index.js"; +import { resolveRuntimeCwd, spawnHostProcess, spawnHostPty } from "./host-process.js"; +import { hostWorkspaceHelper } from "./host-helper.js"; +import type { HostGitObservationOwner } from "./host-git-observation.js"; +import { createRuntimeStateStore } from "./runtime-state.js"; +import { writePaseoWorktreeFirstAgentBranchAutoNameMetadata } from "../../../utils/worktree-metadata.js"; +import { createExternalProcessEnv } from "../../paseo-env.js"; +import { createStringCommandShellEnv } from "../../../utils/string-command-shell.js"; +import { runGitCommand } from "../../../utils/run-git-command.js"; +import { createRealpathAwarePathMatcher } from "../../../utils/path.js"; + +interface WorktreeRuntimeState { + workspaceId: string; + root: string; + sourceRoot: string; + worktreeRoot: string; + lifecycle: "ready" | "paused"; + lifecycleEnvironment: Readonly>; + branchName?: string; + relativeCwd?: string; + ownsWorktree?: boolean; +} + +export function createWorktreeRuntime(options: { + paseoHome: string; + worktreesRoot?: string; + hostGitObservations: HostGitObservationOwner; +}): WorkspaceRuntimeDriver { + const states = createRuntimeStateStore(options.paseoHome, "worktree", isWorktreeRuntimeState); + + async function inspect(workspaceId: string): Promise { + const state = await states.read(workspaceId); + if (!state) return { status: "missing" }; + if (state.lifecycle === "paused") { + return { + status: "paused", + state: publicState(state), + placement: hostPlacement(state.root), + }; + } + try { + if (!(await stat(state.root)).isDirectory()) return { status: "missing" }; + return { + status: state.lifecycle, + state: publicState(state), + placement: hostPlacement(state.root), + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" }; + return { status: "error", message: String(error) }; + } + } + + async function requireReady(workspaceId: string): Promise { + const state = await states.read(workspaceId); + if (!state || state.lifecycle !== "ready") { + throw new Error( + `Workspace runtime worktree is ${state?.lifecycle ?? "missing"}: ${workspaceId}`, + ); + } + return state; + } + + return { + id: "worktree", + requiresGitProject: true, + workspaceHelper: hostWorkspaceHelper, + scriptTerminal: { kind: "persistent-shell" }, + provider: { + environment: "inherit-sanitized-host", + sharedHostProviders: new Set(["opencode"]), + }, + setupEnvironment: () => createStringCommandShellEnv(createExternalProcessEnv(process.env)), + async create(input: WorkspaceDriverCreateInput) { + const existing = await inspect(input.workspaceId); + if (existing.status === "ready" || existing.status === "paused") { + return { ...existing, materializedFreshContent: false }; + } + if (input.project.source.kind !== "host-directory") { + throw new Error("The worktree runtime requires a host Git checkout"); + } + if (input.purpose === "provider-probe") { + if (input.placement.kind !== "existing") { + throw new Error("A worktree provider probe adopts an existing Git checkout"); + } + const sourceRoot = path.resolve(input.project.source.path); + const root = await resolveRuntimeCwd(sourceRoot, input.placement.relativeCwd); + if (!(await stat(root)).isDirectory()) throw new Error(`Directory not found: ${root}`); + const state: WorktreeRuntimeState = { + workspaceId: input.workspaceId, + root, + worktreeRoot: sourceRoot, + sourceRoot, + lifecycle: "ready", + lifecycleEnvironment: {}, + ownsWorktree: false, + }; + await states.write(state); + return { + state: publicState(state), + placement: hostPlacement(root), + materializedFreshContent: false, + }; + } + if (input.placement.kind === "existing") { + throw new Error("The worktree runtime creates and owns its worktree"); + } + const sourceRoot = path.resolve(input.project.source.path); + const worktreeSlug = + input.placement.kind === "resolved-worktree" + ? input.placement.worktreeSlug + : (input.placement.worktreeSlug ?? input.workspaceId); + const worktree = await createWorktree({ + cwd: sourceRoot, + source: toWorktreeSource(input.placement), + worktreeSlug, + runSetup: false, + paseoHome: options.paseoHome, + worktreesRoot: options.worktreesRoot, + }); + try { + if ( + input.markFirstAgentBranchAutoName && + input.placement.kind === "resolved-worktree" && + input.placement.source.kind === "branch-off" + ) { + writePaseoWorktreeFirstAgentBranchAutoNameMetadata(worktree.worktreePath, { + placeholderBranchName: worktree.branchName, + }); + } + const root = await resolveRuntimeCwd( + worktree.worktreePath, + input.placement.relativeCwd, + ).catch((error) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error("Selected project directory is missing from the worktree", { + cause: error, + }); + } + throw error; + }); + if (input.seedPaseoConfigFrom) { + await seedPaseoConfigFile({ sourceCwd: input.seedPaseoConfigFrom, targetCwd: root }); + } + if (!(await stat(root)).isDirectory()) { + throw new Error(`Selected project directory is missing from the worktree: ${root}`); + } + const state: WorktreeRuntimeState = { + workspaceId: input.workspaceId, + root, + worktreeRoot: worktree.worktreePath, + sourceRoot, + lifecycle: "ready", + lifecycleEnvironment: { + PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", + PASEO_SOURCE_CHECKOUT_PATH: sourceRoot, + PASEO_ROOT_PATH: sourceRoot, + PASEO_WORKTREE_PATH: worktree.worktreePath, + PASEO_BRANCH_NAME: worktree.branchName, + }, + branchName: worktree.branchName, + ...(input.placement.relativeCwd ? { relativeCwd: input.placement.relativeCwd } : {}), + ownsWorktree: true, + }; + await states.write(state); + return { + state: publicState(state), + placement: hostPlacement(root), + materializedFreshContent: true, + }; + } catch (error) { + await deletePaseoWorktree({ + cwd: sourceRoot, + worktreePath: worktree.worktreePath, + teardownCwds: [], + paseoHome: options.paseoHome, + worktreesBaseRoot: options.worktreesRoot, + }); + throw error; + } + }, + inspect, + async spawn(input: WorkspaceDriverSpawnInput) { + const root = (await requireReady(input.workspaceId)).root; + return input.stdio.kind === "pty" ? spawnHostPty(root, input) : spawnHostProcess(root, input); + }, + async observeGit(workspaceId, listener) { + return options.hostGitObservations.observe((await requireReady(workspaceId)).root, listener); + }, + async pause(workspaceId) { + const state = await states.read(workspaceId); + if (state?.lifecycle === "paused") return; + if (!state) throw new Error(`Workspace runtime worktree is missing: ${workspaceId}`); + const branchName = state.ownsWorktree === false ? undefined : await currentBranch(state); + await states.write({ + ...state, + lifecycle: "paused", + ...(branchName + ? { + branchName, + lifecycleEnvironment: { + ...state.lifecycleEnvironment, + PASEO_BRANCH_NAME: branchName, + }, + } + : {}), + }); + }, + async releaseBacking(workspaceId) { + const state = await states.read(workspaceId); + if (!state) throw new Error(`Workspace runtime worktree is missing: ${workspaceId}`); + if (state.lifecycle !== "paused") { + throw new Error(`Workspace runtime worktree is not paused: ${workspaceId}`); + } + if (state.ownsWorktree === false) return; + await deletePaseoWorktree({ + cwd: state.sourceRoot, + worktreePath: state.worktreeRoot, + teardownCwds: [], + paseoHome: options.paseoHome, + worktreesBaseRoot: options.worktreesRoot, + }); + }, + async resume(workspaceId) { + const current = await states.read(workspaceId); + if (!current) throw new Error(`Workspace runtime worktree is missing: ${workspaceId}`); + const rematerialized = await rematerializeOwnedWorktree(current, options); + const state = { ...rematerialized, lifecycle: "ready" as const }; + await states.write(state); + return { state: publicState(state), placement: hostPlacement(state.root) }; + }, + async destroy(workspaceId) { + const state = await states.read(workspaceId); + if (!state) return; + if (state.ownsWorktree !== false) { + await deletePaseoWorktree({ + cwd: state.sourceRoot, + worktreePath: state.worktreeRoot, + teardownCwds: [], + paseoHome: options.paseoHome, + worktreesBaseRoot: options.worktreesRoot, + }); + } + await states.remove(workspaceId); + }, + }; +} + +function publicState(state: WorktreeRuntimeState): WorkspaceDriverState { + return { + workspaceId: state.workspaceId, + lifecycle: state.lifecycle, + lifecycleEnvironment: state.lifecycleEnvironment, + }; +} + +function isWorktreeRuntimeState( + value: unknown, + workspaceId: string, +): value is WorktreeRuntimeState { + if (!value || typeof value !== "object") return false; + const state = value as Partial; + return ( + state.workspaceId === workspaceId && + typeof state.root === "string" && + typeof state.sourceRoot === "string" && + typeof state.worktreeRoot === "string" && + (state.lifecycle === "ready" || state.lifecycle === "paused") && + !!state.lifecycleEnvironment && + typeof state.lifecycleEnvironment === "object" && + (state.branchName === undefined || typeof state.branchName === "string") && + (state.relativeCwd === undefined || typeof state.relativeCwd === "string") && + (state.ownsWorktree === undefined || typeof state.ownsWorktree === "boolean") + ); +} + +async function currentBranch(state: WorktreeRuntimeState): Promise { + const { stdout } = await runGitCommand(["branch", "--show-current"], { + cwd: state.worktreeRoot, + }); + const branchName = stdout.trim(); + if (!branchName) { + throw new Error(`Workspace runtime worktree has no current branch: ${state.workspaceId}`); + } + return branchName; +} + +async function rematerializeOwnedWorktree( + state: WorktreeRuntimeState, + options: { paseoHome: string; worktreesRoot?: string }, +): Promise { + const existing = await validateExistingWorktree(state); + if (existing) return existing; + if (state.ownsWorktree === false) { + throw new Error(`Workspace runtime worktree is missing: ${state.workspaceId}`); + } + const branchName = state.branchName ?? state.lifecycleEnvironment.PASEO_BRANCH_NAME; + if (!branchName) { + throw new Error(`Workspace runtime worktree has no restorable branch: ${state.workspaceId}`); + } + try { + if (!(await stat(state.sourceRoot)).isDirectory()) { + throw new Error("The source repository needed to restore this worktree no longer exists."); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error("The source repository needed to restore this worktree no longer exists.", { + cause: error, + }); + } + throw error; + } + await mkdir(path.dirname(state.worktreeRoot), { recursive: true }); + await runGitCommand(["worktree", "add", state.worktreeRoot, branchName], { + cwd: state.sourceRoot, + timeout: 120_000, + }); + try { + const relativeCwd = state.relativeCwd ?? path.relative(state.worktreeRoot, state.root); + const root = await resolveRuntimeCwd(state.worktreeRoot, relativeCwd || undefined).catch( + (error) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error("Selected project directory is missing from the worktree", { + cause: error, + }); + } + throw error; + }, + ); + return { + ...state, + root, + branchName, + ...(relativeCwd ? { relativeCwd } : {}), + lifecycleEnvironment: { + ...state.lifecycleEnvironment, + PASEO_WORKTREE_PATH: state.worktreeRoot, + PASEO_BRANCH_NAME: branchName, + }, + }; + } catch (error) { + await deletePaseoWorktree({ + cwd: state.sourceRoot, + worktreePath: state.worktreeRoot, + teardownCwds: [], + paseoHome: options.paseoHome, + worktreesBaseRoot: options.worktreesRoot, + }); + throw error; + } +} + +async function validateExistingWorktree( + state: WorktreeRuntimeState, +): Promise { + try { + if (!(await stat(state.worktreeRoot)).isDirectory()) return null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + const { exitCode, stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], { + cwd: state.worktreeRoot, + acceptExitCodes: [0, 128], + }); + if (exitCode !== 0 || !createRealpathAwarePathMatcher(state.worktreeRoot)(stdout.trim())) { + throw new Error(`Workspace runtime worktree path is occupied: ${state.worktreeRoot}`); + } + const branchName = await currentBranch(state); + const expectedBranchName = state.branchName ?? state.lifecycleEnvironment.PASEO_BRANCH_NAME; + if (!expectedBranchName || branchName !== expectedBranchName) { + throw new Error( + `Workspace runtime worktree branch changed: expected ${expectedBranchName ?? "unknown"}, received ${branchName}`, + ); + } + const relativeCwd = state.relativeCwd ?? path.relative(state.worktreeRoot, state.root); + try { + const root = await resolveRuntimeCwd(state.worktreeRoot, relativeCwd || undefined); + if ((await stat(root)).isDirectory()) return { ...state, root }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + throw new Error(`Selected project directory is missing from the worktree: ${state.root}`); +} + +function hostPlacement(root: string) { + return { cwd: root, hostVisiblePath: root }; +} + +function toWorktreeSource( + placement: Exclude, +): WorktreeSource { + if (placement.kind === "resolved-worktree") return placement.source as WorktreeSource; + if (placement.kind === "branch") { + return { + kind: "branch-off", + baseBranch: placement.baseRef, + branchName: placement.branchName, + }; + } + return { kind: "checkout-branch", branchName: placement.ref }; +} diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime-boundary.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime-boundary.test.ts new file mode 100644 index 0000000000..d71779bde0 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/workspace-runtime-boundary.test.ts @@ -0,0 +1,79 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { expect, test } from "vitest"; + +const repositoryRoot = fileURLToPath(new URL("../../../../..", import.meta.url)); + +test("core owns only generic command runtime registration", () => { + for (const packagePath of [ + "packages/cli/package.json", + "packages/desktop/package.json", + "packages/server/package.json", + ]) { + const manifest = packageJson(packagePath); + expect(manifest.dependencies, packagePath).not.toHaveProperty( + "@getpaseo/docker-workspace-runtime", + ); + expect(manifest.dependencies, packagePath).not.toHaveProperty( + "@getpaseo/srt-workspace-runtime", + ); + expect(JSON.stringify(manifest.files ?? []), packagePath).not.toMatch( + /(?:docker|srt)-workspace-runtime|runtimes\/(?:docker|srt)/u, + ); + } + + const rootPackage = packageJson("package.json"); + expect(rootPackage.scripts?.["build:server-deps"]).not.toContain("docker-workspace-runtime"); + expect(rootPackage.scripts?.["build:server-deps:clean"]).not.toContain( + "docker-workspace-runtime", + ); + + for (const relativePath of [ + "packages/server/src/server/workspace-runtime/index.ts", + "packages/server/src/server/persisted-config.ts", + "packages/server/src/server/bootstrap.ts", + "packages/app/src/new-workspace-runtime/model.ts", + ]) { + const source = readFileSync(path.join(repositoryRoot, relativePath), "utf8"); + expect(source, relativePath).not.toContain("@getpaseo/docker-workspace-runtime"); + expect(source, relativePath).not.toMatch(/type:\s*["']docker["']/u); + } +}); + +test("production composition and release artifacts exclude runtime implementations", () => { + for (const relativePath of [ + "packages/cli/src/commands/daemon/local-daemon.ts", + "packages/desktop/src/daemon/daemon-manager.ts", + "packages/desktop/electron-builder.yml", + "docker/base/Dockerfile", + ]) { + expect(readFileSync(path.join(repositoryRoot, relativePath), "utf8"), relativePath).not.toMatch( + /@getpaseo\/(?:docker|srt)-workspace-runtime|runtimes\/(?:docker|srt)/u, + ); + } + + const rootPackage = packageJson("package.json"); + const releaseScripts = Object.entries(rootPackage.scripts ?? {}) + .filter(([name]) => name.startsWith("release:")) + .map(([, command]) => command) + .join("\n"); + expect(releaseScripts).not.toMatch(/(?:docker|srt)-workspace-runtime/u); + + const dockerfile = readFileSync(path.join(repositoryRoot, "docker/base/Dockerfile"), "utf8"); + expect(dockerfile).toContain( + "npm pack --workspace=@getpaseo/workspace-runtime-contract --pack-destination /tmp/paseo-packs", + ); + expect(dockerfile).toContain( + "npm pack --workspace=@getpaseo/workspace-helper --pack-destination /tmp/paseo-packs", + ); +}); + +function packageJson(relativePath: string): { + dependencies?: Record; + files?: string[]; + scripts?: Record; +} { + return JSON.parse(readFileSync(path.join(repositoryRoot, relativePath), "utf8")); +} diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts new file mode 100644 index 0000000000..3eb24fa1cd --- /dev/null +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts @@ -0,0 +1,1009 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, watch } from "node:fs"; +import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { afterEach, expect, test as vitestTest, vi } from "vitest"; + +import { createWorkspaceRuntimeService } from "./index.js"; + +const fixtureExecutable = fileURLToPath( + new URL("../../../../../runtimes/fixture/src/index.mjs", import.meta.url), +); +const helperExecutable = fileURLToPath( + new URL("../../../../../packages/workspace-helper/src/executable.mjs", import.meta.url), +); +const rebindHelperExecutable = fileURLToPath( + new URL("../test-utils/fixtures/workspace-helper-rebind-fixture.mjs", import.meta.url), +); +const cleanupRoots: string[] = []; +const test = vitestTest.skipIf(process.platform === "win32"); + +afterEach(async () => { + await Promise.all( + cleanupRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +test("a trusted registered command is selected and receives secret launch data off argv", async () => { + const fixture = await createFixture("registered"); + await fixture.service.create({ + ...fixture.createInput, + setup: [{ argv: ["/bin/sh", "-c", "printf setup > setup-purpose.txt"], env: {} }], + }); + const materializationLaunch = JSON.parse( + await readFile(path.join(fixture.source, ".runtime-launch.json"), "utf8"), + ) as { purpose: unknown }; + expect(materializationLaunch.purpose).toEqual({ kind: "workspace-helper" }); + const secret = "secret-shaped-workload-value"; + const process = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [ + processExecPath(), + "-e", + "require('fs').writeFileSync('workload-secret.txt', process.env.SECRET_TOKEN)", + ], + env: { SECRET_TOKEN: secret }, + purpose: { kind: "workspace-script", script: "secure-envelope-contract" }, + }); + process.stdin.end(); + await expect(process.exited).resolves.toEqual({ code: 0, signal: null }); + expect(await readFile(path.join(fixture.source, "workload-secret.txt"), "utf8")).toBe(secret); + const launch = JSON.parse( + await readFile(path.join(fixture.source, ".runtime-launch.json"), "utf8"), + ) as { argv: string[]; purpose: unknown }; + expect(JSON.stringify(launch.argv)).not.toContain(secret); + expect(launch.purpose).toEqual({ kind: "workspace-script" }); + + await expect( + fixture.service.create({ + ...fixture.createInput, + workspaceId: "unregistered", + runtimeId: "nope", + }), + ).rejects.toThrow("Workspace runtime is not registered: nope"); + await fixture.service.destroy(fixture.workspaceId); +}); + +test("the fixture executable receives generic discovery purpose through the strict lifecycle contract", async () => { + const fixture = await createFixture("provider-probe-purpose"); + await fixture.service.create({ + ...fixture.createInput, + purpose: "provider-probe", + }); + const [stateFile] = await readdir(fixture.stateDirectory); + const state = JSON.parse( + await readFile(path.join(fixture.stateDirectory, stateFile), "utf8"), + ) as { createInput: unknown }; + expect(state.createInput).toEqual({ + workspaceId: fixture.workspaceId, + project: { + projectId: fixture.createInput.project.id, + source: { kind: "directory", path: fixture.createInput.project.source.path }, + }, + placement: fixture.createInput.placement, + purpose: "discovery", + }); + await fixture.service.destroy(fixture.workspaceId); +}); + +test("resolves package, filesystem, and PATH runtime executables without shell parsing", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-command-resolution-")); + cleanupRoots.push(root); + const source = path.join(root, "source"); + await mkdir(source); + await symlink(fixtureExecutable, path.join(root, "runtime.mjs")); + const runtimeIds = new Map(); + const commands = { + package: ["@getpaseo/fixture-workspace-runtime"], + filesystem: [fixtureExecutable], + relative: ["./runtime.mjs"], + environment: ["paseo-fixture-workspace-runtime"], + } as const; + const service = createWorkspaceRuntimeService({ + paseoHome: root, + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => void runtimeIds.set(workspaceId, runtimeId), + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => void runtimeIds.delete(workspaceId), + externalRuntimes: Object.fromEntries( + Object.entries(commands).map(([runtimeId, command]) => [ + runtimeId, + { + type: "command" as const, + command, + options: { stateDirectory: path.join(root, runtimeId) }, + }, + ]), + ), + }); + await Promise.all(Object.keys(commands).map((runtimeId) => mkdir(path.join(root, runtimeId)))); + + for (const runtimeId of Object.keys(commands)) { + const workspaceId = `resolution-${runtimeId}`; + await expect( + service.create({ + workspaceId, + runtimeId, + project: { id: runtimeId, source: { kind: "host-directory", path: source } }, + placement: { kind: "existing" }, + }), + ).resolves.toMatchObject({ workspaceId, runtimeId }); + await service.destroy(workspaceId); + } +}); + +test("launches JavaScript package bins with Node and preserves configured argv", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-command-package-bin-")); + cleanupRoots.push(root); + const source = path.join(root, "source"); + const packageRoot = path.join(root, "node_modules", "@fixture", "runtime"); + const argvFile = path.join(root, "package-bin-argv.json"); + await Promise.all([mkdir(source), mkdir(packageRoot, { recursive: true })]); + await writeFile( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "@fixture/runtime", type: "module", bin: "runtime.js" }), + ); + await writeFile( + path.join(packageRoot, "runtime.js"), + [ + "import { appendFileSync } from 'node:fs';", + "appendFileSync(process.argv[2], `${JSON.stringify(process.argv.slice(2))}\\n`);", + `await import(${JSON.stringify(pathToFileURL(fixtureExecutable).href)});`, + ].join("\n"), + { mode: 0o644 }, + ); + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: root, + commandResolutionBase: root, + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => void runtimeIds.set(workspaceId, runtimeId), + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => void runtimeIds.delete(workspaceId), + externalRuntimes: { + package: { + type: "command", + command: ["@fixture/runtime", argvFile, "--modes", "pipes"], + options: { stateDirectory: path.join(root, "state") }, + }, + }, + }); + + await service.create({ + workspaceId: "package-bin", + runtimeId: "package", + project: { id: "package", source: { kind: "host-directory", path: source } }, + placement: { kind: "existing" }, + }); + const launches = (await readFile(argvFile, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as string[]); + expect( + launches.every( + (argv) => argv.slice(0, 3).join("\0") === [argvFile, "--modes", "pipes"].join("\0"), + ), + ).toBe(true); + expect(launches).toContainEqual([ + argvFile, + "--modes", + "pipes", + "create", + "--workspace-id", + "package-bin", + ]); + await service.destroy("package-bin"); +}); + +test("equal display cwd values never share external runtime execution, files, Git, or caches", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-command-runtime-equal-display-")); + cleanupRoots.push(root); + const sources = [path.join(root, "source-a"), path.join(root, "source-b")]; + const stateDirectories = [path.join(root, "state-a"), path.join(root, "state-b")]; + await Promise.all([...sources, ...stateDirectories].map((directory) => mkdir(directory))); + for (const [index, source] of sources.entries()) { + execFileSync("git", ["init", "-b", "main"], { cwd: source }); + execFileSync("git", ["config", "user.email", "paseo@example.com"], { cwd: source }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: source }); + await writeFile(path.join(source, "tracked.txt"), "base\n"); + execFileSync("git", ["add", "tracked.txt"], { cwd: source }); + execFileSync("git", ["commit", "-m", "base"], { cwd: source }); + await writeFile(path.join(source, `only-${index === 0 ? "a" : "b"}.txt`), "before"); + } + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => void runtimeIds.set(id, runtimeId), + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => void runtimeIds.delete(id), + externalRuntimes: Object.fromEntries( + ["a", "b"].map((suffix, index) => [ + `fixture-${suffix}`, + { + type: "command" as const, + command: [processExecPath(), fixtureExecutable] as const, + options: { + stateDirectory: stateDirectories[index], + displayCwd: "/workspace", + exposeHostVisiblePath: false, + }, + }, + ]), + ), + }); + + for (const [index, suffix] of ["a", "b"].entries()) { + await service.create({ + workspaceId: `equal-${suffix}`, + runtimeId: `fixture-${suffix}`, + project: { + id: `project-${suffix}`, + source: { kind: "host-directory", path: sources[index] }, + }, + placement: { kind: "existing" }, + }); + } + await expect(service.inspect("equal-a")).resolves.toEqual({ status: "ready", cwd: "/workspace" }); + await expect(service.inspect("equal-b")).resolves.toEqual({ status: "ready", cwd: "/workspace" }); + await expect(service.requireHostVisiblePath("equal-a")).rejects.toThrow("no host-visible path"); + expect(await service.bind("equal-a")).not.toBe(await service.bind("equal-b")); + + await expect( + service.files("equal-a").write({ path: "only-a.txt", contents: Buffer.from("a") }), + ).resolves.toMatchObject({ status: "written" }); + await expect( + service.files("equal-b").write({ path: "only-b.txt", contents: Buffer.from("b") }), + ).resolves.toMatchObject({ status: "written" }); + await expect(service.files("equal-a").stat("only-b.txt")).resolves.toMatchObject({ + status: "missing", + }); + await expect(service.files("equal-b").stat("only-a.txt")).resolves.toMatchObject({ + status: "missing", + }); + + const statuses = await Promise.all( + ["a", "b"].map(async (suffix) => { + const workload = await service.run({ + workspaceId: `equal-${suffix}`, + argv: ["git", "status", "--porcelain"], + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + purpose: { kind: "git" }, + }); + workload.stdin.end(); + const output = await collectText(workload.stdout); + await workload.exited; + return output; + }), + ); + expect(statuses[0]).toContain("only-a.txt"); + expect(statuses[0]).not.toContain("only-b.txt"); + expect(statuses[1]).toContain("only-b.txt"); + expect(statuses[1]).not.toContain("only-a.txt"); + + await service.destroy("equal-a"); + await service.destroy("equal-b"); +}); + +test.each(["/tmp", "../outside", "outside-link"])( + "the external runtime itself rejects workspace cwd escape %s", + async (cwd) => { + const fixture = await createFixture(`cwd-${cwd.replaceAll(/[^a-z]/g, "-")}`); + const outside = path.join(fixture.root, "outside"); + await mkdir(outside); + await symlink(outside, path.join(fixture.source, "outside-link"), "dir"); + await fixture.service.create(fixture.createInput); + + const escaped = await fixture.service.run({ + workspaceId: fixture.workspaceId, + cwd, + argv: [processExecPath(), "-e", "process.exit(0)"], + env: {}, + purpose: { kind: "workspace-script", script: "cwd-confinement" }, + }); + escaped.stdin.end(); + await expect(escaped.exited).rejects.toThrow("Workspace cwd escapes its runtime root"); + await fixture.service.destroy(fixture.workspaceId); + }, +); + +test("a command runtime tears down and reconstructs its bound files capability", async () => { + const fixture = await createFixture("files-reconstruction"); + await writeFile(path.join(fixture.source, "watched.txt"), "before\n"); + await fixture.service.create(fixture.createInput); + const files = fixture.service.files(fixture.workspaceId); + const firstEvents: Array<{ type: string; error?: string }> = []; + const first = await files.subscribe({ paths: ["watched.txt"] }, (event) => { + firstEvents.push(event); + }); + + await fixture.service.pause(fixture.workspaceId); + expect(firstEvents).toContainEqual({ + type: "error", + error: "Workspace files client is closed", + }); + await first.unsubscribe(); + await fixture.service.resume(fixture.workspaceId); + await expect(files.list(".")).resolves.toMatchObject({ path: "." }); + + const reconstructedEvents: Array<{ type: string; error?: string }> = []; + await files.subscribe({ paths: ["watched.txt"] }, (event) => { + reconstructedEvents.push(event); + }); + await fixture.service.destroy(fixture.workspaceId); + expect(reconstructedEvents).toContainEqual({ + type: "error", + error: "Workspace files client is closed", + }); +}); + +test("a failed second subscription rebind rolls back the staged observer set before retry", async () => { + const fixture = await createFixture("transactional-rebind", false, "pty", {}, (root) => [ + processExecPath(), + rebindHelperExecutable, + helperExecutable, + path.join(root, "watch-launches"), + ]); + await Promise.all([ + writeFile(path.join(fixture.source, "first.txt"), "before\n"), + writeFile(path.join(fixture.source, "second.txt"), "before\n"), + ]); + await fixture.service.create(fixture.createInput); + const files = fixture.service.files(fixture.workspaceId); + let observeChanges = false; + let resolveFirstChange!: () => void; + let resolveSecondChange!: () => void; + const firstChange = new Promise((resolve) => { + resolveFirstChange = resolve; + }); + const secondChange = new Promise((resolve) => { + resolveSecondChange = resolve; + }); + const subscriptions = await Promise.all([ + files.subscribe({ paths: ["first.txt"] }, (event) => { + if (observeChanges && event.type === "changed") resolveFirstChange(); + }), + files.subscribe({ paths: ["second.txt"] }, (event) => { + if (observeChanges && event.type === "changed") resolveSecondChange(); + }), + ]); + + await fixture.service.pause(fixture.workspaceId); + await expect(fixture.service.resume(fixture.workspaceId)).rejects.toThrow( + "Workspace helper subscribe acknowledgement timed out", + ); + await expect(files.list(".")).rejects.toThrow( + `Workspace runtime is recovering: ${fixture.workspaceId}`, + ); + await fixture.service.resume(fixture.workspaceId); + + observeChanges = true; + await Promise.all([ + writeFile(path.join(fixture.source, "first.txt"), "after\n"), + writeFile(path.join(fixture.source, "second.txt"), "after\n"), + ]); + await Promise.all([firstChange, secondChange]); + expect(Number(await readFile(path.join(fixture.root, "watch-launches"), "utf8"))).toBe(4); + + await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe())); + expect( + execFileSync("ps", ["-axo", "command="], { encoding: "utf8" }) + .split("\n") + .some( + (command) => + command.includes("workspace-helper-rebind-fixture.mjs") && + command.includes(path.basename(fixture.root)), + ), + ).toBe(false); + await fixture.service.destroy(fixture.workspaceId); +}, 10_000); + +test("a file operation racing pause cannot reconstruct the closing helper client", async () => { + const fixture = await createFixture("files-pause-race", true); + await writeFile(path.join(fixture.source, "watched.txt"), "before\n"); + await fixture.service.create(fixture.createInput); + const files = fixture.service.files(fixture.workspaceId); + await files.list("."); + await writeFile(path.join(fixture.barrierDirectory, "block-next-inspect"), "block"); + const inspectEntered = nextFile(fixture.barrierDirectory, "inspect-entered"); + + const racingList = files.list("."); + await inspectEntered; + await fixture.service.pause(fixture.workspaceId); + await writeFile(path.join(fixture.barrierDirectory, "release-inspect"), "release"); + + await expect(racingList).rejects.toThrow(`Workspace runtime is paused: ${fixture.workspaceId}`); + await fixture.service.resume(fixture.workspaceId); + await expect(files.list(".")).resolves.toMatchObject({ path: "." }); + await fixture.service.destroy(fixture.workspaceId); +}); + +test("the command runtime transports PTY input, Unicode output, resize, and signals", async () => { + const fixture = await createFixture("pty"); + await fixture.service.create(fixture.createInput); + const secret = "terminal-secret-value"; + const terminal = await fixture.service.openTerminal({ + workspaceId: fixture.workspaceId, + argv: [ + processExecPath(), + "-e", + "process.stdin.setEncoding('utf8');process.stdout.write(`${process.stdout.isTTY}|${process.stdout.columns}x${process.stdout.rows}|λ|${process.env.SECRET}`);process.stdin.once('data',data=>{process.stdout.write(`|${data.trim()}|${process.stdout.columns}x${process.stdout.rows}`);process.exit(9)})", + ], + env: { SECRET: secret, PATH: process.env.PATH ?? "" }, + purpose: { kind: "terminal", terminalId: "command-pty" }, + rows: 24, + cols: 80, + }); + let output = ""; + terminal.onData((data) => { + output += data; + }); + await vi.waitFor(() => expect(output).toContain(`true|80x24|λ|${secret}`)); + terminal.resize(99, 41); + terminal.write("héllo\n"); + await expect(terminal.exited).resolves.toEqual({ code: 9, signal: null }); + expect(output).toContain("|héllo|99x41"); + const launch = JSON.parse( + await readFile(path.join(fixture.source, ".runtime-launch.json"), "utf8"), + ) as { argv: string[] }; + expect(JSON.stringify(launch.argv)).not.toContain(secret); + + const signaled = await fixture.service.openTerminal({ + workspaceId: fixture.workspaceId, + argv: ["/bin/sleep", "30"], + env: { PATH: "/usr/bin:/bin" }, + purpose: { kind: "terminal", terminalId: "command-signal" }, + rows: 24, + cols: 80, + }); + signaled.kill("SIGTERM"); + await expect(signaled.exited).resolves.toEqual({ code: null, signal: "SIGTERM" }); + const forced = await fixture.service.openTerminal({ + workspaceId: fixture.workspaceId, + argv: ["/bin/sleep", "30"], + env: { PATH: "/usr/bin:/bin" }, + purpose: { kind: "terminal", terminalId: "command-force" }, + rows: 24, + cols: 80, + }); + forced.kill("SIGKILL"); + await expect(forced.exited).resolves.toEqual({ code: null, signal: "SIGKILL" }); + await fixture.service.destroy(fixture.workspaceId); +}); + +test("a registered pipes-only command runtime fails closed when asked for a PTY", async () => { + const fixture = await createFixture("pipes-only", false, "pipes"); + await fixture.service.create(fixture.createInput); + + await expect( + fixture.service.openTerminal({ + workspaceId: fixture.workspaceId, + argv: ["/bin/sh"], + env: {}, + purpose: { kind: "terminal", terminalId: "unsupported-pty" }, + rows: 24, + cols: 80, + }), + ).rejects.toThrow("Workspace runtime fixture does not support PTY mode"); + + await fixture.service.destroy(fixture.workspaceId); +}); + +test.each(["success", "error", "hang"] as const)( + "a crashed pipes wrapper reaps its detached workload when the signal helper ends with %s", + async (signalHelperResult) => { + const fixture = await createFixture(`pipes-wrapper-crash-${signalHelperResult}`, false, "pty", { + crashPipeWrapper: true, + ...(signalHelperResult === "success" ? {} : { signalHelperFailure: signalHelperResult }), + }); + const pidFile = path.join(fixture.source, `crashed-pipe-${signalHelperResult}.pid`); + await fixture.service.create(fixture.createInput); + const workload = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [ + processExecPath(), + "-e", + `require('node:fs').writeFileSync(${JSON.stringify(pidFile)},String(process.pid));setInterval(()=>{},1000)`, + ], + env: {}, + purpose: { kind: "workspace-script", script: "wrapper-crash" }, + }); + workload.stdin.end(); + + await expect(workload.exited).rejects.toThrow( + signalHelperResult === "success" + ? "pipes wrapper ended without a valid fd4 exit event" + : "cleanup failed", + ); + const workloadPid = Number(await readFile(pidFile, "utf8")); + expect(processExists(workloadPid)).toBe(false); + const later = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [processExecPath(), "-e", "process.exit(0)"], + env: {}, + purpose: { kind: "workspace-script", script: "later-execution" }, + }); + later.stdin.end(); + await expect(later.exited).resolves.toEqual({ code: 0, signal: null }); + await fixture.service.destroy(fixture.workspaceId); + }, + 5_000, +); + +test("a command runtime protocol version mismatch fails with the authored and expected versions", async () => { + const fixture = await createFixture("version-mismatch", false, "pty", { + describeProtocolVersion: 2, + }); + + await expect(fixture.service.create(fixture.createInput)).rejects.toThrow( + "unsupported command protocol version 2; expected 1", + ); +}); + +test("the fd4 workload exit remains authoritative after the wrapper exits", async () => { + const fixture = await createFixture("delayed-pty-exit", false, "pty", { + delayedPtyExitEvent: true, + }); + await fixture.service.create(fixture.createInput); + const terminal = await fixture.service.openTerminal({ + workspaceId: fixture.workspaceId, + argv: [processExecPath(), "-e", "process.exit(6)"], + env: {}, + purpose: { kind: "terminal", terminalId: "delayed-exit" }, + rows: 24, + cols: 80, + }); + + await expect(terminal.exited).resolves.toEqual({ code: 6, signal: null }); + await fixture.service.destroy(fixture.workspaceId); +}); + +test("a wrapper exit without an fd4 workload exit rejects", async () => { + const fixture = await createFixture("missing-pty-exit", false, "pty", { + omitPtyExitEvent: true, + }); + await fixture.service.create(fixture.createInput); + const terminal = await fixture.service.openTerminal({ + workspaceId: fixture.workspaceId, + argv: [processExecPath(), "-e", "process.exit(6)"], + env: {}, + purpose: { kind: "terminal", terminalId: "missing-exit" }, + rows: 24, + cols: 80, + }); + + await expect(terminal.exited).rejects.toThrow("ended without a valid fd4 exit event"); + await fixture.service.destroy(fixture.workspaceId); +}); + +test("an invalid fd4 event rejects and terminates the wrapper workload", async () => { + const fixture = await createFixture("invalid-pty-event", false, "pty", { + invalidPtyEvent: true, + }); + const pidFile = path.join(fixture.source, "invalid-event.pid"); + await fixture.service.create(fixture.createInput); + const terminal = await fixture.service.openTerminal({ + workspaceId: fixture.workspaceId, + argv: [ + processExecPath(), + "-e", + `require('node:fs').writeFileSync(${JSON.stringify(pidFile)},String(process.pid));setInterval(()=>{},1000)`, + ], + env: {}, + purpose: { kind: "terminal", terminalId: "invalid-event" }, + rows: 24, + cols: 80, + }); + + await expect(terminal.exited).rejects.toThrow("Invalid discriminator value"); + const workloadPid = Number(await readFile(pidFile, "utf8")); + expect(processExists(workloadPid)).toBe(false); + await fixture.service.destroy(fixture.workspaceId); +}); + +test.each([ + ["exit-before-eof", "pipes", ["started", "exit", "eof"], "exit before eof"], + ["eof-before-started", "pipes", ["eof", "started", "exit"], "eof before started"], + ["duplicate-started", "pipes", ["started", "started"], "duplicate started"], + ["duplicate-eof", "pipes", ["started", "eof", "eof"], "duplicate eof"], + ["duplicate-exit", "pipes", ["started", "eof", "exit", "exit"], "duplicate exit"], + ["post-exit-event", "pipes", ["started", "eof", "exit", "eof"], "event after exit"], + ["pty-resize-before-started", "pty", ["resized"], "resized before started"], + ["pty-resize-after-eof", "pty", ["started", "eof", "resized"], "resized after eof"], +] as const)( + "an external %s fd4 violation rejects only after its exact workload is absent", + async (name, mode, processEventSequence, expected) => { + const pidRoot = await mkdtemp(path.join(tmpdir(), `paseo-fd4-${name}-`)); + cleanupRoots.push(pidRoot); + const pidFile = path.join(pidRoot, "workload.pid"); + const barrierFile = path.join(pidRoot, "release-events"); + const fixture = await createFixture(`fd4-${name}`, false, "pty", { + processEventSequence, + processEventPurposeKind: mode === "pty" ? "terminal" : "workspace-script", + recordWorkloadPidAt: pidFile, + processEventBarrierPath: barrierFile, + }); + await fixture.service.create(fixture.createInput); + let workloadPid: number | undefined; + try { + const workload = + mode === "pty" + ? await fixture.service.openTerminal({ + workspaceId: fixture.workspaceId, + argv: [processExecPath(), "-e", "setInterval(()=>{},1000)"], + env: {}, + purpose: { kind: "terminal", terminalId: name }, + rows: 24, + cols: 80, + }) + : await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [processExecPath(), "-e", "setInterval(()=>{},1000)"], + env: {}, + purpose: { kind: "workspace-script", script: name }, + }); + if (workload.kind === "pipes") workload.stdin.end(); + await writeFile(barrierFile, "release"); + const failure = await workload.exited.then( + () => null, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain(expected); + workloadPid = Number(await readFile(pidFile, "utf8")); + expect(processExists(workloadPid)).toBe(false); + } finally { + if (workloadPid && processExists(workloadPid)) { + try { + process.kill(-workloadPid, "SIGKILL"); + } catch { + // The assertion above owns the cleanup verdict; this is failure-only containment. + } + } + await fixture.service.destroy(fixture.workspaceId); + } + }, + 8_000, +); + +test("a failed PTY control channel rejects and terminates the wrapper workload", async () => { + const fixture = await createFixture("failed-pty-control", false, "pty", { + closePtyControl: true, + }); + const pidFile = path.join(fixture.source, "failed-control.pid"); + await fixture.service.create(fixture.createInput); + const terminal = await fixture.service.openTerminal({ + workspaceId: fixture.workspaceId, + argv: [ + processExecPath(), + "-e", + `require('node:fs').writeFileSync(${JSON.stringify(pidFile)},String(process.pid));setInterval(()=>{},1000)`, + ], + env: {}, + purpose: { kind: "terminal", terminalId: "failed-control" }, + rows: 24, + cols: 80, + }); + const workloadPid = await vi.waitFor(async () => { + const pid = Number(await readFile(pidFile, "utf8")); + expect(processExists(pid)).toBe(true); + return pid; + }); + await new Promise((resolve) => setTimeout(resolve, 150)); + + terminal.resize(100, 40); + + await expect(terminal.exited).rejects.toThrow("PTY resize acknowledgement timed out"); + expect(processExists(workloadPid)).toBe(false); + await fixture.service.destroy(fixture.workspaceId); +}); + +test("an invalid resize is rejected before PTY state or workload changes", async () => { + const fixture = await createFixture("invalid-resize"); + await fixture.service.create(fixture.createInput); + const terminal = await fixture.service.openTerminal({ + workspaceId: fixture.workspaceId, + argv: [ + processExecPath(), + "-e", + "process.stdin.setEncoding('utf8');process.stdin.once('data',data=>{process.stdout.write(`${data.trim()}|${process.stdout.columns}x${process.stdout.rows}`);process.exit(0)})", + ], + env: {}, + purpose: { kind: "terminal", terminalId: "invalid-resize" }, + rows: 24, + cols: 80, + }); + let output = ""; + terminal.onData((data) => { + output += data; + }); + + expect(() => terminal.resize(0, 40)).toThrow(); + terminal.resize(101, 37); + terminal.write("alive\n"); + + await expect(terminal.exited).resolves.toEqual({ code: 0, signal: null }); + expect(output).toContain("alive|101x37"); + await fixture.service.destroy(fixture.workspaceId); +}); + +test.each(["error", "hang"] as const)( + "PTY cleanup is bounded when the signal helper ends with %s", + async (signalHelperFailure) => { + const fixture = await createFixture(`pty-signal-helper-${signalHelperFailure}`, false, "pty", { + invalidPtyEvent: true, + signalHelperFailure, + }); + const pidFile = path.join(fixture.source, `signal-helper-${signalHelperFailure}.pid`); + await fixture.service.create(fixture.createInput); + const terminal = await fixture.service.openTerminal({ + workspaceId: fixture.workspaceId, + argv: [ + processExecPath(), + "-e", + `require('node:fs').writeFileSync(${JSON.stringify(pidFile)},String(process.pid));setInterval(()=>{},1000)`, + ], + env: {}, + purpose: { kind: "terminal", terminalId: `signal-helper-${signalHelperFailure}` }, + rows: 24, + cols: 80, + }); + + await expect(terminal.exited).rejects.toThrow("cleanup failed"); + const workloadPid = Number(await readFile(pidFile, "utf8")); + expect(processExists(workloadPid)).toBe(false); + await fixture.service.destroy(fixture.workspaceId); + }, + 5_000, +); + +test("archive and permanent deletion reap runtime-owned processes when forced cleanup hangs", async () => { + const descendantPidFileName = "signal-helper-descendant.pid"; + const fixture = await createFixture("lifecycle-hanging-cleanup", false, "pty", { + signalHelperFailure: "hang", + signalHelperDescendantPidFileName: descendantPidFileName, + }); + const pidFile = path.join(fixture.source, "lifecycle-workload.pid"); + const descendantPidFile = path.join(fixture.source, descendantPidFileName); + await fixture.service.create(fixture.createInput); + const workload = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [ + processExecPath(), + "-e", + `require('node:fs').writeFileSync(${JSON.stringify(pidFile)},String(process.pid));process.on('SIGTERM',()=>{});setInterval(()=>{},1000)`, + ], + env: {}, + purpose: { kind: "provider-probe", provider: "codex" }, + }); + workload.stdin.end(); + const workloadPid = await vi.waitFor(async () => { + const pid = Number(await readFile(pidFile, "utf8")); + expect(processExists(pid)).toBe(true); + return pid; + }); + let descendantPid: number | undefined; + + try { + await fixture.service.archive(fixture.workspaceId); + descendantPid = Number(await readFile(descendantPidFile, "utf8")); + expect(fixture.archivedWorkspaceIds).toContain(fixture.workspaceId); + await expect(workload.exited).rejects.toThrow("forced process cleanup failed"); + expect(processExists(workloadPid)).toBe(false); + expect(processExists(descendantPid)).toBe(false); + expect(runtimeAdapterProcesses(fixture.workspaceId)).toEqual([]); + + await fixture.service.destroy(fixture.workspaceId); + await fixture.service.destroy(fixture.workspaceId); + expect(fixture.archivedWorkspaceIds).not.toContain(fixture.workspaceId); + expect(await readdir(fixture.stateDirectory)).toEqual([]); + expect(runtimeAdapterProcesses(fixture.workspaceId)).toEqual([]); + } finally { + if (processExists(workloadPid)) { + try { + process.kill(-workloadPid, "SIGKILL"); + } catch { + // Failure-only containment for the process ownership assertion above. + } + } + if (descendantPid && processExists(descendantPid)) process.kill(descendantPid, "SIGKILL"); + await fixture.service.destroy(fixture.workspaceId).catch(() => undefined); + } +}, 10_000); + +test("run admission racing pause cannot leave an unregistered workload running", async () => { + const fixture = await createFixture("race", true); + await fixture.service.create(fixture.createInput); + await writeFile(path.join(fixture.barrierDirectory, "block-next-inspect"), "block"); + + const runPromise = fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [processExecPath(), "-e", "process.on('SIGTERM',()=>{});setInterval(()=>{},1000)"], + env: {}, + purpose: { kind: "workspace-script", script: "pause-race-contract" }, + }); + await vi.waitFor(() => + expect(existsSync(path.join(fixture.barrierDirectory, "inspect-entered"))).toBe(true), + ); + const pausePromise = fixture.service.pause(fixture.workspaceId); + await writeFile(path.join(fixture.barrierDirectory, "release-inspect"), "release"); + + const workload = await runPromise; + workload.stdin.end(); + await pausePromise; + await expect(workload.exited).resolves.toMatchObject({ code: null }); + await expect( + fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "paused-admission-contract" }, + }), + ).rejects.toThrow(`Workspace runtime is paused: ${fixture.workspaceId}`); + await fixture.service.resume(fixture.workspaceId); + await fixture.service.destroy(fixture.workspaceId); +}, 15_000); + +test("an existing runtime selection cannot be switched before target driver dispatch", async () => { + const fixture = await createFixture("immutable-selection"); + await fixture.service.create({ ...fixture.createInput, runtimeId: "local" }); + + await expect(fixture.service.create(fixture.createInput)).rejects.toThrow( + `Workspace runtime is already selected as local: ${fixture.workspaceId}`, + ); + expect(await readdir(fixture.stateDirectory)).toEqual([]); + + await fixture.service.destroy(fixture.workspaceId); +}); + +test("isolated command runtimes apply lifecycle environment to pipes and PTY", async () => { + const fixture = await createFixture("lifecycle-environment", false, "pty", { + lifecycleEnvironment: { + HOME: "/runtime/home", + TMPDIR: "/runtime/tmp", + RUNTIME_VALUE: "runtime", + }, + }); + await fixture.service.create(fixture.createInput); + const workload = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [ + processExecPath(), + "-e", + "process.stdout.write(JSON.stringify({home:process.env.HOME,tmp:process.env.TMPDIR,value:process.env.RUNTIME_VALUE,caller:process.env.CALLER_VALUE}))", + ], + env: { RUNTIME_VALUE: "caller", CALLER_VALUE: "caller" }, + purpose: { kind: "workspace-script", script: "lifecycle-environment" }, + }); + workload.stdin.end(); + await expect(collectText(workload.stdout)).resolves.toBe( + JSON.stringify({ + home: "/runtime/home", + tmp: "/runtime/tmp", + value: "runtime", + caller: "caller", + }), + ); + await expect(workload.exited).resolves.toEqual({ code: 0, signal: null }); + + const terminal = await fixture.service.openTerminal({ + workspaceId: fixture.workspaceId, + argv: [processExecPath(), "-e", "process.stdout.write(process.env.RUNTIME_VALUE)"], + env: { RUNTIME_VALUE: "caller" }, + purpose: { kind: "terminal", terminalId: "lifecycle-environment" }, + rows: 24, + cols: 80, + }); + let output = ""; + terminal.onData((data) => { + output += data; + }); + await expect(terminal.exited).resolves.toEqual({ code: 0, signal: null }); + expect(output).toContain("runtime"); + await fixture.service.destroy(fixture.workspaceId); +}, 15_000); + +async function createFixture( + name: string, + withBarrier = false, + modes: "pipes" | "pty" = "pty", + runtimeOptions: Readonly> = {}, + fixtureHelperCommand?: (root: string) => readonly [string, ...string[]], +) { + const root = await mkdtemp(path.join(tmpdir(), `paseo-command-runtime-${name}-`)); + cleanupRoots.push(root); + const source = path.join(root, "source"); + const stateDirectory = path.join(root, "state"); + const barrierDirectory = path.join(root, "barrier"); + await Promise.all([mkdir(source), mkdir(stateDirectory), mkdir(barrierDirectory)]); + const runtimeIds = new Map(); + const archivedWorkspaceIds = new Set(); + const workspaceId = `${name}-workspace`; + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => { + runtimeIds.set(id, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + archiveWorkspaceRecord: async (id) => void archivedWorkspaceIds.add(id), + restoreWorkspaceRecord: async (id) => void archivedWorkspaceIds.delete(id), + removeWorkspaceRecord: async (id) => { + runtimeIds.delete(id); + archivedWorkspaceIds.delete(id); + }, + externalRuntimes: { + fixture: { + type: "command", + command: [ + processExecPath(), + fixtureExecutable, + ...(modes === "pipes" ? ["--modes", "pipes"] : []), + ...(runtimeOptions.describeProtocolVersion === undefined + ? [] + : ["--protocol-version", String(runtimeOptions.describeProtocolVersion)]), + ], + options: { + stateDirectory, + ...(fixtureHelperCommand ? { fixtureHelperCommand: fixtureHelperCommand(root) } : {}), + ...runtimeOptions, + ...(withBarrier ? { inspectBarrierDirectory: barrierDirectory } : {}), + }, + }, + }, + }); + return { + root, + source, + stateDirectory, + barrierDirectory, + workspaceId, + archivedWorkspaceIds, + service, + createInput: { + workspaceId, + runtimeId: "fixture", + project: { id: `${name}-project`, source: { kind: "host-directory" as const, path: source } }, + placement: { kind: "existing" as const }, + }, + }; +} + +function processExecPath(): string { + return process.execPath; +} + +async function collectText(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } +} + +function runtimeAdapterProcesses(workspaceId: string): string[] { + return execFileSync("ps", ["-axo", "command="], { encoding: "utf8" }) + .split("\n") + .filter((command) => command.includes(fixtureExecutable) && command.includes(workspaceId)); +} + +function nextFile(directory: string, expectedName: string): Promise { + return new Promise((resolve, reject) => { + const watcher = watch(directory, (_event, filename) => { + if (filename?.toString() !== expectedName) return; + watcher.close(); + resolve(); + }); + watcher.once("error", reject); + }); +} 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 new file mode 100644 index 0000000000..58371e7ffa --- /dev/null +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts @@ -0,0 +1,1167 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { + createWorkspaceRuntimeService, + type CreateWorkspaceInput, + type WorkspaceRuntimeOptions, + type WorkspaceRuntimeService, +} from "./index.js"; +import { observeWorkspaceGit } from "../workspace-git-observation.js"; + +const cleanupRoots: string[] = []; +const posixDescribe = describe.runIf(process.platform !== "win32"); +const runtimeContractIds = ["local", "worktree", "fixture"] as const; +const fixtureRuntimeExecutable = fileURLToPath( + new URL("../../../../../runtimes/fixture/src/index.mjs", import.meta.url), +); + +afterEach(async () => { + await Promise.all( + cleanupRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +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], + }, + }, + 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, + }, + ]); +}); + +test("has no implicit command runtime registration in core", async () => { + const root = await temporaryRoot("catalog-no-implicit-runtime"); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "home"), + externalRuntimes: { + fixture: { + type: "command", + label: "Fixture", + command: [process.execPath, fixtureRuntimeExecutable], + }, + }, + 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: "unregistered-command-runtime", + runtimeId: "docker", + project: { + id: "unregistered-command-runtime", + source: { kind: "host-directory", path: root }, + }, + placement: { kind: "existing" }, + }), + ).rejects.toThrow("Workspace runtime is not registered: docker"); +}); + +posixDescribe("setup eligibility", () => { + test("adopting an existing Local directory never executes its paseo.json setup", async () => { + const fixture = await createFixture("local"); + const marker = path.join(fixture.repo, "local-adoption-setup-ran.txt"); + await writeFile( + path.join(fixture.repo, "paseo.json"), + JSON.stringify({ + worktree: { setup: ["printf setup > local-adoption-setup-ran.txt"] }, + }), + ); + + await expect( + fixture.service.create({ + ...fixture.createInput, + }), + ).resolves.toMatchObject({ runtimeId: "local", cwd: fixture.repo }); + + expect(existsSync(marker)).toBe(false); + await fixture.service.destroy(fixture.workspaceId); + }); +}); + +posixDescribe("setup lifecycle environment", () => { + test("supplies main's lifecycle variables only for setup execution", async () => { + const fixture = await createFixture("worktree"); + await fixture.service.create(fixture.createInput); + const runtime = await fixture.service.bind(fixture.workspaceId); + const setup = await runtime.run({ + argv: [ + "/bin/sh", + "-c", + 'printf \'%s\\n\' "$PASEO_SOURCE_CHECKOUT_PATH" "$PASEO_ROOT_PATH" "$PASEO_WORKTREE_PATH" "$PASEO_BRANCH_NAME" "$PASEO_WORKTREE_PORT"', + ], + env: {}, + purpose: { kind: "setup" }, + }); + setup.stdin.end(); + const output = (await collect(setup.stdout)).trim().split("\n"); + await expect(setup.exited).resolves.toEqual({ code: 0, signal: null }); + + expect(await realpath(output[0]!)).toBe(await realpath(fixture.repo)); + expect(output[1]).toBe(output[0]); + expect(await realpath(output[2]!)).toBe(await realpath(fixture.runtimeRoot())); + expect(output[3]).toBeTruthy(); + expect(Number(output[4])).toBeGreaterThan(0); + await fixture.service.destroy(fixture.workspaceId); + }); +}); + +posixDescribe("service lifecycle", () => { + test("close releases runtime processes and observations without destroying backing", async () => { + const fixture = await createFixture("worktree"); + await fixture.service.create(fixture.createInput); + const runtime = await fixture.service.bind(fixture.workspaceId); + const observation = await observeWorkspaceGit(runtime, () => undefined); + const child = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [process.execPath, "-e", "setInterval(() => {}, 1000)"], + env: {}, + purpose: { kind: "workspace-script", script: "daemon-shutdown" }, + }); + child.stdin.end(); + + await fixture.service.close(); + + await expect(child.exited).resolves.toEqual({ code: null, signal: "SIGTERM" }); + expect(existsSync(fixture.runtimeRoot())).toBe(true); + expect(fixture.runtimeIds.get(fixture.workspaceId)).toBe("worktree"); + await observation.unsubscribe(); + + await createWorkspaceRuntimeService(fixture.options).destroy(fixture.workspaceId); + }); +}); + +posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId) => { + test("returns after materialization without waiting for configured setup", async () => { + const fixture = await createFixture(runtimeId); + await writeFile( + path.join(fixture.repo, "paseo.json"), + JSON.stringify({ worktree: { setup: ["while [ ! -f setup-release ]; do sleep 1; done"] } }), + ); + + const created = await fixture.service.create(fixture.createInput); + + expect(created.materializedFreshContent).toBe(runtimeId !== "local"); + expect(existsSync(path.join(fixture.runtimeRoot(), "setup-release"))).toBe(false); + await fixture.service.destroy(fixture.workspaceId); + }); + + test("creates a provider probe by adopting the project root without setup", async () => { + const fixture = await createFixture(runtimeId); + const setupMarker = path.join(fixture.repo, "probe-setup-ran.txt"); + + const created = await fixture.service.create({ + ...fixture.createInput, + placement: { kind: "existing" }, + purpose: "provider-probe", + setup: [{ argv: ["/bin/sh", "-c", "printf setup > probe-setup-ran.txt"], env: {} }], + }); + + expect(await realpath(created.cwd)).toBe(await realpath(fixture.repo)); + expect(existsSync(setupMarker)).toBe(false); + if (runtimeId === "worktree") { + await expect( + Promise.all(listLinkedWorktrees(fixture.repo).map((cwd) => realpath(cwd))), + ).resolves.toEqual([await realpath(fixture.repo)]); + } + await fixture.service.destroy(fixture.workspaceId); + expect(existsSync(fixture.repo)).toBe(true); + }); + + test("bound runtime exposes only process, file, and command-resolution primitives", async () => { + const fixture = await createFixture(runtimeId); + await fixture.service.create(fixture.createInput); + + const runtime = await fixture.service.bind(fixture.workspaceId); + + expect(Object.keys(runtime).sort()).toEqual([ + "files", + "provider", + "resolveCommand", + "run", + "scriptTerminal", + ]); + await expect(runtime.resolveCommand("git")).resolves.toMatch(/^\//u); + await expect(runtime.resolveCommand("paseo-command-that-does-not-exist")).resolves.toBeNull(); + await fixture.service.destroy(fixture.workspaceId); + }); + + test("binds streaming files and live observation to the selected runtime", async () => { + const fixture = await createFixture(runtimeId); + await fixture.service.create(fixture.createInput); + const files = fixture.service.files(fixture.workspaceId); + + const listing = await files.list("."); + expect(listing.entries.map((entry) => entry.name)).toContain("committed.txt"); + const initial = await files.stat("committed.txt"); + expect(initial).toMatchObject({ status: "ready", size: 10 }); + if (initial.status !== "ready") throw new Error("Expected committed.txt to exist"); + + let resolveChanged!: () => void; + const watcherEvents: Array<{ type: string; error?: string }> = []; + const changed = new Promise((resolve) => { + resolveChanged = resolve; + }); + const subscription = await files.subscribe({ paths: ["committed.txt"] }, (event) => { + watcherEvents.push(event); + if (event.type === "changed" && event.paths.includes("committed.txt")) resolveChanged(); + }); + const terminalEdit = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: ["/bin/sh", "-c", "printf changed > committed.txt"], + env: {}, + purpose: { kind: "terminal", terminalId: `${runtimeId}-file-edit` }, + }); + terminalEdit.stdin.end(); + await expect(terminalEdit.exited).resolves.toEqual({ code: 0, signal: null }); + await expect(changed).resolves.toBeUndefined(); + + const streamed = await files.read("committed.txt"); + await expect(collect(streamed.chunks)).resolves.toBe("changed"); + await fixture.service.pause(fixture.workspaceId); + expect(watcherEvents).toContainEqual({ + type: "error", + error: "Workspace files client is closed", + }); + await expect(files.list(".")).rejects.toThrow(`Workspace runtime is paused`); + await subscription.unsubscribe(); + await fixture.service.resume(fixture.workspaceId); + await expect(files.list(".")).resolves.toMatchObject({ path: "." }); + const reconstructedEvents: Array<{ type: string; error?: string }> = []; + await files.subscribe({ paths: ["committed.txt"] }, (event) => { + reconstructedEvents.push(event); + }); + await fixture.service.destroy(fixture.workspaceId); + expect(reconstructedEvents).toContainEqual({ + type: "error", + error: "Workspace files client is closed", + }); + }); + + test("opens an interactive PTY with input, Unicode output, resize, EOF, and signals", async () => { + const fixture = await createFixture(runtimeId); + await fixture.service.create(fixture.createInput); + + const terminal = await fixture.service.openTerminal({ + workspaceId: fixture.workspaceId, + argv: [ + process.execPath, + "-e", + "process.stdin.setEncoding('utf8');process.stdout.write(`${process.cwd()}|${process.stdout.isTTY}|${process.stdout.columns}x${process.stdout.rows}|λ`);process.stdin.once('data',data=>{const finish=()=>{process.stdout.write(`|${data.trim()}|${process.stdout.columns}x${process.stdout.rows}`);process.exit(7)};process.stdout.columns===101?finish():process.stdout.once('resize',finish)})", + ], + env: { PATH: process.env.PATH ?? "" }, + purpose: { kind: "terminal", terminalId: `${runtimeId}-terminal` }, + rows: 24, + cols: 80, + term: "xterm-256color", + }); + const output = collectTerminal(terminal); + await waitForTerminalOutput(output, "|true|80x24|λ"); + terminal.resize(101, 37); + terminal.write("héllo\n"); + await expect(terminal.exited).resolves.toEqual({ code: 7, signal: null }); + expect(output.value()).toContain("|héllo|101x37"); + + const signaled = await fixture.service.openTerminal({ + workspaceId: fixture.workspaceId, + argv: ["/bin/sleep", "30"], + env: { PATH: "/usr/bin:/bin" }, + purpose: { kind: "terminal", terminalId: `${runtimeId}-signal-terminal` }, + rows: 24, + cols: 80, + }); + signaled.kill("SIGTERM"); + await expect(signaled.exited).resolves.toEqual({ code: null, signal: "SIGTERM" }); + + const forced = await fixture.service.openTerminal({ + workspaceId: fixture.workspaceId, + argv: ["/bin/sleep", "30"], + env: { PATH: "/usr/bin:/bin" }, + purpose: { kind: "terminal", terminalId: `${runtimeId}-force-terminal` }, + rows: 24, + cols: 80, + }); + forced.kill("SIGKILL"); + await expect(forced.exited).resolves.toEqual({ code: null, signal: "SIGKILL" }); + + await fixture.service.destroy(fixture.workspaceId); + }); + + test("creates, pipes an exact environment, preserves status, and owns only its resources", async () => { + const fixture = await createFixture(runtimeId); + const created = await fixture.service.create(fixture.createInput); + const compatibilityCwd = + runtimeId === "worktree" ? await realpath(fixture.runtimeRoot()) : fixture.runtimeRoot(); + expect(created).toEqual({ + workspaceId: fixture.workspaceId, + runtimeId, + cwd: compatibilityCwd, + ...(runtimeId === "fixture" ? {} : { hostVisiblePath: compatibilityCwd }), + materializedFreshContent: runtimeId !== "local", + }); + await expect(fixture.service.inspect(fixture.workspaceId)).resolves.toEqual({ + status: "ready", + cwd: compatibilityCwd, + ...(runtimeId === "fixture" ? {} : { hostVisiblePath: compatibilityCwd }), + }); + if (runtimeId === "fixture") { + await expect(fixture.service.requireHostVisiblePath(fixture.workspaceId)).rejects.toThrow( + `Workspace has no host-visible path: ${fixture.workspaceId}`, + ); + } else { + await expect(fixture.service.requireHostVisiblePath(fixture.workspaceId)).resolves.toBe( + compatibilityCwd, + ); + } + + const runtimeRoot = fixture.runtimeRoot(); + expect(await readFile(path.join(runtimeRoot, "committed.txt"), "utf8")).toBe("committed\n"); + expect(existsSync(path.join(runtimeRoot, "setup-owned.txt"))).toBe(false); + + const child = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [ + "/bin/sh", + "-c", + "cat > runtime-owned.txt; printf runtime-stdout; printf runtime-stderr >&2; exit 23", + ], + env: { RUNTIME_EXACT_ENV: runtimeId }, + purpose: { kind: "workspace-script", script: "public-contract" }, + }); + child.stdin.end(`${runtimeId}-state`); + const [stdout, stderr, exit] = await Promise.all([ + collect(child.stdout), + collect(child.stderr), + child.exited, + ]); + expect({ stdout, stderr, exit }).toEqual({ + stdout: "runtime-stdout", + stderr: "runtime-stderr", + exit: { code: 23, signal: null }, + }); + + const env = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: ["/usr/bin/env"], + env: { RUNTIME_EXACT_ENV: runtimeId }, + purpose: { kind: "workspace-script", script: "environment-contract" }, + }); + env.stdin.end(); + await expect(collect(env.stdout)).resolves.toBe(`RUNTIME_EXACT_ENV=${runtimeId}\n`); + await expect(env.exited).resolves.toEqual({ code: 0, signal: null }); + + const signaled = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: ["/bin/sleep", "30"], + env: {}, + purpose: { kind: "workspace-script", script: "signal-contract" }, + }); + signaled.stdin.end(); + signaled.kill("SIGTERM"); + await expect(signaled.exited).resolves.toEqual({ code: null, signal: "SIGTERM" }); + + await fixture.service.pause(fixture.workspaceId); + const recovered = createWorkspaceRuntimeService(fixture.options); + await expect( + recovered.run({ + workspaceId: fixture.workspaceId, + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "paused-contract" }, + }), + ).rejects.toThrow(`Workspace runtime is paused: ${fixture.workspaceId}`); + await recovered.resume(fixture.workspaceId); + expect(await readFile(path.join(runtimeRoot, "runtime-owned.txt"), "utf8")).toBe( + `${runtimeId}-state`, + ); + + await recovered.destroy(fixture.workspaceId); + expect(existsSync(fixture.repo)).toBe(true); + expect(existsSync(runtimeRoot)).toBe(runtimeId !== "worktree"); + await expect( + recovered.run({ + workspaceId: fixture.workspaceId, + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "missing-contract" }, + }), + ).rejects.toThrow(`Workspace runtime is not selected: ${fixture.workspaceId}`); + }); + + test("repeated create preserves setup eligibility and paused state", async () => { + const fixture = await createFixture(runtimeId); + const setupMarker = "idempotent-setup.txt"; + const input = { + ...fixture.createInput, + setup: [ + { + argv: ["/bin/sh", "-c", `printf 'setup\\n' >> ${setupMarker}`] as const, + env: {}, + }, + ], + }; + await fixture.service.create(input); + await fixture.service.pause(fixture.workspaceId); + await fixture.service.create(input); + expect(existsSync(path.join(fixture.runtimeRoot(), setupMarker))).toBe(false); + await expect( + fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "idempotence-contract" }, + }), + ).rejects.toThrow(`Workspace runtime is paused: ${fixture.workspaceId}`); + await fixture.service.resume(fixture.workspaceId); + await fixture.service.destroy(fixture.workspaceId); + }); + + test("archive, restore, reconstruction, and permanent deletion converge", async () => { + const fixture = await createFixture(runtimeId, { lifecycleRecords: true }); + await fixture.service.create(fixture.createInput); + const runtimeRoot = fixture.runtimeRoot(); + await writeFile(path.join(runtimeRoot, "dirty-untracked.txt"), "preserved\n"); + let resolveRestoredChange!: (paths: string[]) => void; + const restoredChange = new Promise((resolve) => { + resolveRestoredChange = resolve; + }); + const subscription = await fixture.service + .files(fixture.workspaceId) + .subscribe({ paths: ["dirty-untracked.txt"] }, (event) => { + if (event.type === "changed") resolveRestoredChange(event.paths); + }); + + await Promise.all([ + fixture.service.archive(fixture.workspaceId, { releaseBacking: true }), + fixture.service.archive(fixture.workspaceId, { releaseBacking: true }), + ]); + expect(fixture.archivedWorkspaceIds.has(fixture.workspaceId)).toBe(true); + if (runtimeId === "worktree") { + expect(existsSync(runtimeRoot)).toBe(false); + } + await expect(fixture.service.inspect(fixture.workspaceId)).resolves.toMatchObject({ + status: "paused", + }); + + await Promise.all([ + fixture.service.restore(fixture.workspaceId), + fixture.service.restore(fixture.workspaceId), + ]); + expect(fixture.archivedWorkspaceIds.has(fixture.workspaceId)).toBe(false); + expect(existsSync(runtimeRoot)).toBe(true); + await writeFile(path.join(runtimeRoot, "dirty-untracked.txt"), "observed\n"); + await expect(restoredChange).resolves.toEqual(["dirty-untracked.txt"]); + await subscription.unsubscribe(); + + await fixture.service.archive(fixture.workspaceId); + const reconstructed = createWorkspaceRuntimeService(fixture.options); + await expect( + reconstructed.run({ + workspaceId: fixture.workspaceId, + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "archived-admission" }, + }), + ).rejects.toThrow(`Workspace runtime is paused: ${fixture.workspaceId}`); + await Promise.all([ + reconstructed.restore(fixture.workspaceId), + reconstructed.restore(fixture.workspaceId), + ]); + expect(fixture.archivedWorkspaceIds.has(fixture.workspaceId)).toBe(false); + if (runtimeId !== "worktree") { + expect(await readFile(path.join(runtimeRoot, "dirty-untracked.txt"), "utf8")).toBe( + "observed\n", + ); + } + + await Promise.all([ + reconstructed.destroy(fixture.workspaceId), + reconstructed.destroy(fixture.workspaceId), + ]); + expect(fixture.runtimeIds.has(fixture.workspaceId)).toBe(false); + expect(existsSync(fixture.repo)).toBe(true); + expect(existsSync(runtimeRoot)).toBe(runtimeId !== "worktree"); + }); + + test("registry failures leave lifecycle transitions retryable without opening an admission gap", async () => { + const fixture = await createFixture(runtimeId, { lifecycleRecords: true }); + await fixture.service.create(fixture.createInput); + fixture.lifecycleFailures.archive = 1; + await expect(fixture.service.archive(fixture.workspaceId)).rejects.toThrow( + "archive persistence failed", + ); + await expect( + fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "failed-archive-barrier" }, + }), + ).rejects.toThrow(`Workspace runtime is paused: ${fixture.workspaceId}`); + await fixture.service.archive(fixture.workspaceId); + + fixture.lifecycleFailures.restore = 1; + await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow( + "restore persistence failed", + ); + await expect( + fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "failed-restore-barrier" }, + }), + ).rejects.toThrow(`Workspace runtime is recovering: ${fixture.workspaceId}`); + await fixture.service.restore(fixture.workspaceId); + const admitted = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: ["/usr/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "restored-admission" }, + }); + admitted.stdin.end(); + await expect(admitted.exited).resolves.toEqual({ code: 0, signal: null }); + await fixture.service.destroy(fixture.workspaceId); + }); + + test("permanent deletion survives intent and final-record persistence failures", async () => { + const fixture = await createFixture(runtimeId, { lifecycleRecords: true }); + await fixture.service.create(fixture.createInput); + const runtimeRoot = fixture.runtimeRoot(); + await writeFile(path.join(runtimeRoot, "adopted-state.txt"), "keep local data\n"); + + fixture.lifecycleFailures.beginDelete = 1; + await expect(fixture.service.destroy(fixture.workspaceId)).rejects.toThrow( + "delete intent persistence failed", + ); + expect(existsSync(runtimeRoot)).toBe(true); + const usable = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: ["/usr/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "failed-delete-intent" }, + }); + usable.stdin.end(); + await expect(usable.exited).resolves.toEqual({ code: 0, signal: null }); + + fixture.lifecycleFailures.remove = 1; + await expect(fixture.service.destroy(fixture.workspaceId)).rejects.toThrow( + "record removal persistence failed", + ); + expect(fixture.deletingWorkspaceIds.has(fixture.workspaceId)).toBe(true); + expect(fixture.runtimeIds.has(fixture.workspaceId)).toBe(true); + expect(existsSync(runtimeRoot)).toBe(runtimeId !== "worktree"); + + const reconstructed = createWorkspaceRuntimeService(fixture.options); + await reconstructed.reconcile(); + await reconstructed.destroy(fixture.workspaceId); + expect(fixture.runtimeIds.has(fixture.workspaceId)).toBe(false); + expect(existsSync(fixture.repo)).toBe(true); + if (runtimeId !== "worktree") { + await expect(readFile(path.join(runtimeRoot, "adopted-state.txt"), "utf8")).resolves.toBe( + "keep local data\n", + ); + } + }); + + test("startup reconciliation converges interrupted archive and restore transitions", async () => { + const fixture = await createFixture(runtimeId, { lifecycleRecords: true }); + await fixture.service.create(fixture.createInput); + await fixture.service.pause(fixture.workspaceId); + + const reconstructed = createWorkspaceRuntimeService(fixture.options); + await reconstructed.reconcile(); + const resumed = await reconstructed.run({ + workspaceId: fixture.workspaceId, + argv: ["/usr/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "reconciled-resume" }, + }); + resumed.stdin.end(); + await expect(resumed.exited).resolves.toEqual({ code: 0, signal: null }); + + await reconstructed.archive(fixture.workspaceId); + await reconstructed.resume(fixture.workspaceId); + const afterInterruptedRestore = createWorkspaceRuntimeService(fixture.options); + await afterInterruptedRestore.reconcile(); + await expect( + afterInterruptedRestore.run({ + workspaceId: fixture.workspaceId, + argv: ["/usr/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "reconciled-archive" }, + }), + ).rejects.toThrow(`Workspace runtime is paused: ${fixture.workspaceId}`); + await afterInterruptedRestore.destroy(fixture.workspaceId); + }); + + test("archive hooks execute inside the selected runtime exactly once", async () => { + const fixture = await createFixture(runtimeId, { lifecycleRecords: true }); + await fixture.service.create(fixture.createInput); + const configure = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [ + process.execPath, + "-e", + `require("node:fs").writeFileSync("paseo.json", ${JSON.stringify( + JSON.stringify({ + worktree: { teardown: ["printf archived >> archive-hook.txt"] }, + }), + )})`, + ], + env: {}, + purpose: { kind: "setup" }, + }); + configure.stdin.end(); + await expect(configure.exited).resolves.toEqual({ code: 0, signal: null }); + + await fixture.service.archive(fixture.workspaceId); + await fixture.service.archive(fixture.workspaceId); + expect(await readFile(path.join(fixture.runtimeRoot(), "archive-hook.txt"), "utf8")).toBe( + "archived", + ); + await fixture.service.restore(fixture.workspaceId); + await fixture.service.destroy(fixture.workspaceId); + }); +}); + +posixDescribe("worktree exact restore", () => { + test("rejects a preserved worktree whose branch changed", async () => { + const fixture = await createFixture("worktree", { lifecycleRecords: true }); + await fixture.service.create(fixture.createInput); + const runtimeRoot = fixture.runtimeRoot(); + await fixture.service.archive(fixture.workspaceId); + execFileSync("git", ["switch", "-c", "unexpected-branch"], { + cwd: runtimeRoot, + stdio: "pipe", + }); + + await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow( + "Workspace runtime worktree branch changed: expected runtime-branch, received unexpected-branch", + ); + + execFileSync("git", ["switch", "runtime-branch"], { cwd: runtimeRoot, stdio: "pipe" }); + await fixture.service.restore(fixture.workspaceId); + await fixture.service.destroy(fixture.workspaceId); + }); + + test("rejects a selected subdirectory replaced by an escaping symlink", async () => { + const fixture = await createFixture("worktree", { lifecycleRecords: true }); + await mkdir(path.join(fixture.repo, "nested")); + await writeFile(path.join(fixture.repo, "nested", "file.txt"), "nested\n"); + execFileSync("git", ["add", "."], { cwd: fixture.repo, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add nested"], { + cwd: fixture.repo, + stdio: "pipe", + }); + await fixture.service.create({ + ...fixture.createInput, + placement: { + ...(fixture.createInput.placement as Extract< + CreateWorkspaceInput["placement"], + { kind: "branch" } + >), + relativeCwd: "nested", + }, + }); + const runtimeRoot = fixture.runtimeRoot(); + await fixture.service.archive(fixture.workspaceId); + await rm(path.join(runtimeRoot, "nested"), { recursive: true }); + await symlink(fixture.root, path.join(runtimeRoot, "nested")); + + await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow(); + expect(await realpath(path.join(runtimeRoot, "nested"))).toBe(await realpath(fixture.root)); + }); + + test("fails closed when the saved path is occupied", async () => { + const fixture = await createFixture("worktree", { lifecycleRecords: true }); + await fixture.service.create(fixture.createInput); + const runtimeRoot = fixture.runtimeRoot(); + await fixture.service.archive(fixture.workspaceId, { releaseBacking: true }); + await mkdir(runtimeRoot, { recursive: true }); + + await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow( + `Workspace runtime worktree path is occupied: ${runtimeRoot}`, + ); + expect(listLinkedWorktrees(fixture.repo)).not.toContain(runtimeRoot); + + await rm(runtimeRoot, { recursive: true, force: true }); + await fixture.service.restore(fixture.workspaceId); + await fixture.service.destroy(fixture.workspaceId); + }); + + test("reports a missing source repository before attempting rematerialization", async () => { + const fixture = await createFixture("worktree", { lifecycleRecords: true }); + await fixture.service.create(fixture.createInput); + await fixture.service.archive(fixture.workspaceId, { releaseBacking: true }); + const displacedRepo = `${fixture.repo}-missing`; + await rename(fixture.repo, displacedRepo); + + await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow( + "The source repository needed to restore this worktree no longer exists.", + ); + + await rename(displacedRepo, fixture.repo); + await fixture.service.restore(fixture.workspaceId); + await fixture.service.destroy(fixture.workspaceId); + }); + + test("fails closed when the saved branch is checked out elsewhere", async () => { + const fixture = await createFixture("worktree", { lifecycleRecords: true }); + await fixture.service.create(fixture.createInput); + const runtimeRoot = fixture.runtimeRoot(); + await fixture.service.archive(fixture.workspaceId, { releaseBacking: true }); + const otherRoot = path.join(fixture.root, "branch-owner"); + execFileSync("git", ["worktree", "add", otherRoot, "runtime-branch"], { + cwd: fixture.repo, + stdio: "pipe", + }); + + await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow(); + expect(existsSync(runtimeRoot)).toBe(false); + + execFileSync("git", ["worktree", "remove", "--force", otherRoot], { + cwd: fixture.repo, + stdio: "pipe", + }); + await fixture.service.restore(fixture.workspaceId); + await fixture.service.destroy(fixture.workspaceId); + }); + + test("removes a partial restore when the selected subdirectory is missing", async () => { + const fixture = await createFixture("worktree", { lifecycleRecords: true }); + await mkdir(path.join(fixture.repo, "nested")); + await writeFile(path.join(fixture.repo, "nested", "file.txt"), "nested\n"); + execFileSync("git", ["add", "."], { cwd: fixture.repo, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add nested"], { + cwd: fixture.repo, + stdio: "pipe", + }); + const createInput: CreateWorkspaceInput = { + ...fixture.createInput, + placement: { + ...(fixture.createInput.placement as Extract< + CreateWorkspaceInput["placement"], + { kind: "branch" } + >), + relativeCwd: "nested", + }, + }; + await fixture.service.create(createInput); + const runtimeRoot = fixture.runtimeRoot(); + await fixture.service.archive(fixture.workspaceId, { releaseBacking: true }); + execFileSync("git", ["branch", "-f", "runtime-branch", "main~1"], { + cwd: fixture.repo, + stdio: "pipe", + }); + + await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow( + "Selected project directory is missing from the worktree", + ); + expect(existsSync(runtimeRoot)).toBe(false); + }); +}); + +posixDescribe("reconstruction placement recovery", () => { + test("refreshes persisted compatibility cwd from driver inspect", async () => { + const root = await temporaryRoot("reconcile-compatibility-cwd"); + const repo = await createRepository(root); + const runtimeIds = new Map(); + const placements = new Map(); + const options: WorkspaceRuntimeOptions = { + paseoHome: path.join(root, "home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId, placement) => { + runtimeIds.set(workspaceId, runtimeId); + placements.set(workspaceId, placement); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + placements.delete(workspaceId); + }, + listRuntimeRecords: async () => [ + { workspaceId: "recover-cwd", runtimeId: "local", archived: false }, + ], + }; + const service = createWorkspaceRuntimeService(options); + await service.create({ + workspaceId: "recover-cwd", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: repo } }, + placement: { kind: "existing" }, + }); + placements.set("recover-cwd", { cwd: "/stale/presentation/path" }); + + await createWorkspaceRuntimeService(options).reconcile(); + + expect(placements.get("recover-cwd")).toEqual({ cwd: repo, hostVisiblePath: repo }); + await service.destroy("recover-cwd"); + }); +}); + +posixDescribe("fail-closed and producer cleanup", () => { + test("missing and unregistered selections never fall back to the host", async () => { + const root = await temporaryRoot("fail-closed"); + const runtimeIds = new Map(); + const service = createService(root, runtimeIds); + await expect( + service.run({ + workspaceId: "missing", + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "missing-selection" }, + }), + ).rejects.toThrow("Workspace runtime is not selected: missing"); + runtimeIds.set("missing", "not-registered"); + await expect( + service.run({ + workspaceId: "missing", + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "unregistered-selection" }, + }), + ).rejects.toThrow("Workspace runtime is not registered: not-registered"); + await expect( + service.create({ + workspaceId: "unknown", + runtimeId: "not-registered", + project: { id: "project", source: { kind: "host-directory", path: root } }, + placement: { kind: "existing" }, + }), + ).rejects.toThrow("Workspace runtime is not registered: not-registered"); + }); + + test("persistence failure destroys the newly-created worktree", async () => { + const root = await temporaryRoot("persist-cleanup"); + const repo = await createRepository(root); + const worktreesRoot = path.join(root, "worktrees"); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "home"), + worktreesRoot, + resolveRuntimeId: async () => null, + persistRuntimeId: async () => { + throw new Error("persistence failed"); + }, + }); + await expect( + service.create({ + workspaceId: "../../hostile-id", + runtimeId: "worktree", + project: { id: "project", source: { kind: "host-directory", path: repo } }, + placement: { + kind: "branch", + branchName: "persist-cleanup", + baseRef: "main", + worktreeSlug: "persist-cleanup", + }, + }), + ).rejects.toThrow("persistence failed"); + expect(listLinkedWorktrees(repo)).toHaveLength(1); + expect(existsSync(path.join(root, "hostile-id.json"))).toBe(false); + }); + + test("persistence failure removes newly-created local driver state", async () => { + const root = await temporaryRoot("local-persist-cleanup"); + const repo = await createRepository(root); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "home"), + resolveRuntimeId: async () => null, + persistRuntimeId: async () => { + throw new Error("persistence failed"); + }, + }); + await expect( + service.create({ + workspaceId: "../../hostile-local-id", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: repo } }, + placement: { kind: "existing" }, + }), + ).rejects.toThrow("persistence failed"); + await expect( + service.run({ + workspaceId: "../../hostile-local-id", + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "cleanup-contract" }, + }), + ).rejects.toThrow("Workspace runtime is not selected"); + expect(existsSync(path.join(root, "hostile-local-id.json"))).toBe(false); + }); +}); + +posixDescribe.each(runtimeContractIds)( + "%s runtime confinement and process teardown", + (runtimeId) => { + test("rejects a symlink cwd that resolves outside the runtime root", async () => { + const fixture = await createFixture(runtimeId); + await fixture.service.create(fixture.createInput); + const outside = path.join(fixture.root, "outside"); + await mkdir(outside); + await symlink(outside, path.join(fixture.runtimeRoot(), "escape")); + const attempt = fixture.service.run({ + workspaceId: fixture.workspaceId, + cwd: "escape", + argv: ["/bin/pwd"], + env: {}, + purpose: { kind: "workspace-script", script: "cwd-contract" }, + }); + if (runtimeId === "fixture") { + const process = await attempt; + process.stdin.end(); + const stderr = collect(process.stderr); + await expect(process.exited).rejects.toThrow("Workspace cwd escapes its runtime root"); + await expect(stderr).resolves.toContain("Workspace cwd escapes its runtime root"); + } else { + await expect(attempt).rejects.toThrow("Workspace cwd escapes its runtime root"); + } + await fixture.service.destroy(fixture.workspaceId); + }); + + test("pause escalates past ignored SIGTERM and leaves no descendant", async () => { + const fixture = await createFixture(runtimeId); + await fixture.service.create(fixture.createInput); + const child = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [ + process.execPath, + "-e", + "process.on('SIGTERM',()=>{});const c=require('child_process').spawn(process.execPath,['-e',\"process.on('SIGTERM',()=>{});setInterval(()=>{},1000)\"],{stdio:'ignore'});require('fs').writeFileSync('descendant.pid',String(c.pid));setInterval(()=>{},1000)", + ], + env: {}, + purpose: { kind: "workspace-script", script: "teardown-contract" }, + }); + child.stdin.end(); + const pidFile = path.join(fixture.runtimeRoot(), "descendant.pid"); + await vi.waitFor(() => expect(existsSync(pidFile)).toBe(true)); + const descendantPid = Number((await readFile(pidFile, "utf8")).trim()); + const startedAt = Date.now(); + await fixture.service.pause(fixture.workspaceId); + expect(Date.now() - startedAt).toBeLessThan(4_000); + await expect(child.exited).resolves.toEqual({ code: null, signal: "SIGKILL" }); + await vi.waitFor(() => expect(isProcessAlive(descendantPid)).toBe(false)); + await fixture.service.resume(fixture.workspaceId); + await fixture.service.destroy(fixture.workspaceId); + }); + }, +); + +async function createFixture( + runtimeId: "local" | "worktree" | "fixture", + fixtureOptions: { lifecycleRecords?: boolean } = {}, +) { + const root = await temporaryRoot(runtimeId); + const repo = await createRepository(root); + const runtimeIds = new Map(); + const archivedWorkspaceIds = new Set(); + const deletingWorkspaceIds = new Set(); + const lifecycleFailures = { archive: 0, restore: 0, beginDelete: 0, remove: 0 }; + const worktreesRoot = path.join(root, "worktrees"); + const fixtureStateDirectory = path.join(root, "fixture-state"); + await mkdir(fixtureStateDirectory); + const options: WorkspaceRuntimeOptions = { + paseoHome: path.join(root, "home"), + worktreesRoot, + externalRuntimes: + runtimeId === "fixture" + ? { + fixture: { + type: "command", + command: [process.execPath, fixtureRuntimeExecutable], + options: { stateDirectory: fixtureStateDirectory }, + }, + } + : undefined, + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, selectedRuntimeId) => { + runtimeIds.set(workspaceId, selectedRuntimeId); + }, + beginWorkspaceDeletion: async (workspaceId) => { + if (lifecycleFailures.beginDelete > 0) { + lifecycleFailures.beginDelete -= 1; + throw new Error("delete intent persistence failed"); + } + deletingWorkspaceIds.add(workspaceId); + }, + removeWorkspaceRecord: async (workspaceId) => { + if (lifecycleFailures.remove > 0) { + lifecycleFailures.remove -= 1; + throw new Error("record removal persistence failed"); + } + runtimeIds.delete(workspaceId); + archivedWorkspaceIds.delete(workspaceId); + deletingWorkspaceIds.delete(workspaceId); + }, + ...(fixtureOptions.lifecycleRecords + ? { + archiveWorkspaceRecord: async (workspaceId: string) => { + if (lifecycleFailures.archive > 0) { + lifecycleFailures.archive -= 1; + throw new Error("archive persistence failed"); + } + archivedWorkspaceIds.add(workspaceId); + }, + restoreWorkspaceRecord: async (workspaceId: string) => { + if (lifecycleFailures.restore > 0) { + lifecycleFailures.restore -= 1; + throw new Error("restore persistence failed"); + } + archivedWorkspaceIds.delete(workspaceId); + }, + listRuntimeRecords: async () => + [...runtimeIds].map(([workspaceId, selectedRuntimeId]) => ({ + workspaceId, + runtimeId: selectedRuntimeId, + archived: archivedWorkspaceIds.has(workspaceId), + deleting: deletingWorkspaceIds.has(workspaceId), + })), + } + : {}), + }; + const workspaceId = `${runtimeId}-workspace`; + const createInput: CreateWorkspaceInput = { + workspaceId, + runtimeId, + project: { id: `${runtimeId}-project`, source: { kind: "host-directory", path: repo } }, + placement: + runtimeId !== "worktree" + ? { kind: "existing" } + : { + kind: "branch", + branchName: "runtime-branch", + baseRef: "main", + worktreeSlug: "runtime-worktree", + }, + setup: + runtimeId === "worktree" + ? [{ argv: ["/bin/sh", "-c", "printf 'setup\\n' > setup-owned.txt"], env: {} }] + : undefined, + }; + return { + root, + repo, + options, + runtimeIds, + archivedWorkspaceIds, + deletingWorkspaceIds, + lifecycleFailures, + workspaceId, + createInput, + service: createWorkspaceRuntimeService(options), + runtimeRoot: () => + runtimeId !== "worktree" + ? repo + : listLinkedWorktrees(repo).find((cwd) => path.basename(cwd) === "runtime-worktree")!, + }; +} + +function createService(root: string, runtimeIds: Map): WorkspaceRuntimeService { + return createWorkspaceRuntimeService({ + paseoHome: path.join(root, "home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => { + runtimeIds.set(workspaceId, runtimeId); + }, + }); +} + +async function temporaryRoot(name: string): Promise { + const root = await mkdtemp(path.join(tmpdir(), `paseo-runtime-${name}-`)); + cleanupRoots.push(root); + return root; +} + +async function createRepository(root: string): Promise { + const repo = path.join(root, "repo"); + await mkdir(repo); + execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { cwd: repo }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repo }); + await writeFile(path.join(repo, "committed.txt"), "committed\n"); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], { cwd: repo }); + return repo; +} + +function listLinkedWorktrees(repo: string): string[] { + return execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repo, encoding: "utf8" }) + .split("\n") + .filter((line) => line.startsWith("worktree ")) + .map((line) => line.slice("worktree ".length)); +} + +async function collect(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +function collectTerminal(terminal: { onData(listener: (data: string) => void): () => void }): { + value(): string; +} { + let output = ""; + terminal.onData((data) => { + output += data; + }); + return { value: () => output }; +} + +async function waitForTerminalOutput(output: { value(): string }, marker: string): Promise { + await vi.waitFor(() => expect(output.value()).toContain(marker)); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } +} diff --git a/packages/server/src/server/workspace-service-port-allocator.test.ts b/packages/server/src/server/workspace-service-port-allocator.test.ts index c955434872..4859794bdb 100644 --- a/packages/server/src/server/workspace-service-port-allocator.test.ts +++ b/packages/server/src/server/workspace-service-port-allocator.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import net from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -60,7 +60,7 @@ describe("allocateWorkspaceServicePort", () => { branchName: "feature/allocator-context", }), ).resolves.toBe(port); - expect(readFileSync(join(tempDir, "cwd"), "utf8")).toBe(tempDir); + expect(readFileSync(join(tempDir, "cwd"), "utf8")).toBe(realpathSync(tempDir)); expect(readFileSync(join(tempDir, "argv"), "utf8")).toBe( `app-server|wks_port_allocator|feature/allocator-context|${tempDir}`, ); diff --git a/packages/server/src/server/workspace-service-port-allocator.ts b/packages/server/src/server/workspace-service-port-allocator.ts index 7d0dd78884..e10f033d27 100644 --- a/packages/server/src/server/workspace-service-port-allocator.ts +++ b/packages/server/src/server/workspace-service-port-allocator.ts @@ -2,6 +2,7 @@ import net from "node:net"; import { execCommand } from "../utils/spawn.js"; import { findFreePort } from "./service-proxy.js"; import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema"; +import type { BoundWorkspaceRuntime } from "./workspace-runtime/index.js"; const PORT_SCRIPT_TIMEOUT_MS = 10_000; const PORT_SCRIPT_MAX_OUTPUT_BYTES = 1024; @@ -19,6 +20,7 @@ export interface AllocateWorkspaceServicePortOptions { scriptName: string; workspaceId: string; branchName: string | null; + runtime?: Pick; reservedPorts?: ReadonlySet; } @@ -33,6 +35,7 @@ export async function allocateWorkspaceServicePort( workspaceId: options.workspaceId, branchName: options.branchName, reservedPorts: options.reservedPorts, + runtime: options.runtime, }); } if (options.allocation?.range) { @@ -51,25 +54,32 @@ async function allocatePortFromScript(options: { workspaceId: string; branchName: string | null; reservedPorts: ReadonlySet | undefined; + runtime: Pick | undefined; }): Promise { let result: { stdout: string; stderr: string }; try { - result = await execCommand( + const argv: [string, ...string[]] = [ options.command, - [options.scriptName, options.workspaceId, options.branchName ?? "", options.cwd], - { - cwd: options.cwd, - envOverlay: { - PASEO_SCRIPTNAME: options.scriptName, - PASEO_WORKSPACE_ID: options.workspaceId, - PASEO_BRANCH_NAME: options.branchName ?? "", - PASEO_WORKTREE_PATH: options.cwd, - }, - timeout: PORT_SCRIPT_TIMEOUT_MS, - maxBuffer: PORT_SCRIPT_MAX_OUTPUT_BYTES, - shell: false, - }, - ); + options.scriptName, + options.workspaceId, + options.branchName ?? "", + options.cwd, + ]; + const env = { + PASEO_SCRIPTNAME: options.scriptName, + PASEO_WORKSPACE_ID: options.workspaceId, + PASEO_BRANCH_NAME: options.branchName ?? "", + PASEO_WORKTREE_PATH: options.cwd, + }; + result = options.runtime + ? await runInWorkspace(options.runtime, argv, env) + : await execCommand(options.command, argv.slice(1), { + cwd: options.cwd, + envOverlay: env, + timeout: PORT_SCRIPT_TIMEOUT_MS, + maxBuffer: PORT_SCRIPT_MAX_OUTPUT_BYTES, + shell: false, + }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new Error(`Service port script '${options.command}' failed: ${detail}`, { cause: error }); @@ -91,6 +101,53 @@ async function allocatePortFromScript(options: { return port; } +async function runInWorkspace( + runtime: Pick, + argv: [string, ...string[]], + env: Record, +): Promise<{ stdout: string; stderr: string }> { + const executable = await runtime.resolveCommand(argv[0]); + if (!executable) throw new Error(`command is unavailable in the workspace: ${argv[0]}`); + const process = await runtime.run({ + argv: [executable, ...argv.slice(1)], + cwd: ".", + env, + purpose: { kind: "workspace-script", script: "service-port-allocation" }, + }); + process.stdin.end(); + let timeout: NodeJS.Timeout | undefined; + try { + const timedOut = new Promise((_, reject) => { + timeout = setTimeout(() => { + process.kill("SIGKILL"); + reject(new Error(`timed out after ${PORT_SCRIPT_TIMEOUT_MS}ms`)); + }, PORT_SCRIPT_TIMEOUT_MS); + }); + const [stdout, stderr, exit] = await Promise.race([ + Promise.all([readBounded(process.stdout), readBounded(process.stderr), process.exited]), + timedOut, + ]); + if (exit.code !== 0 || exit.signal !== null) { + throw new Error(`exited with ${exit.code ?? exit.signal}: ${stderr || stdout}`); + } + return { stdout, stderr }; + } finally { + if (timeout) clearTimeout(timeout); + } +} + +async function readBounded(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of stream) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.length; + if (bytes > PORT_SCRIPT_MAX_OUTPUT_BYTES) throw new Error("output exceeded limit"); + chunks.push(buffer); + } + return Buffer.concat(chunks).toString("utf8"); +} + async function allocatePortFromRange( range: PortRange, reservedPorts: ReadonlySet, diff --git a/packages/server/src/server/worktree-bootstrap.posix.test.ts b/packages/server/src/server/worktree-bootstrap.posix.test.ts index a0a796c83d..39a008c5e9 100644 --- a/packages/server/src/server/worktree-bootstrap.posix.test.ts +++ b/packages/server/src/server/worktree-bootstrap.posix.test.ts @@ -44,7 +44,7 @@ async function cleanupTerminalManager(terminalManager: TerminalManager): Promise ); const terminals = terminalsByCwd.flat(); await Promise.all(terminals.map((terminal) => killTerminal(terminalManager, terminal))); - terminalManager.killAll(); + await terminalManager.killAll(); } function killTerminal(terminalManager: TerminalManager, terminal: TerminalSession): Promise { diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index c878ef1387..7c27fc750e 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -36,7 +36,7 @@ async function cleanupTerminalManager(terminalManager: TerminalManager): Promise ); const terminals = terminalsByCwd.flat(); await Promise.all(terminals.map((terminal) => killTerminal(terminalManager, terminal))); - terminalManager.killAll(); + await terminalManager.killAll(); } function killTerminal(terminalManager: TerminalManager, terminal: TerminalSession): Promise { @@ -310,6 +310,8 @@ describe("runAsyncWorktreeBootstrap", () => { name?: string; title?: string; env?: Record; + command?: string; + args?: string[]; } interface StubTerminalRecord { @@ -542,6 +544,89 @@ describe("runAsyncWorktreeBootstrap", () => { }); }); + it("launches selected-runtime scripts directly in their public cwd", async () => { + const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + const createTerminalCalls: CreateTerminalCall[] = []; + const terminalRecords: StubTerminalRecord[] = []; + + await spawnWorkspaceScript({ + repoRoot: repoDir, + runtimeCwd: "/workspace", + runtime: { + scriptTerminal: { kind: "direct-command", command: "/bin/sh", argsPrefix: ["-lc"] }, + run: async () => { + throw new Error("unused"); + }, + resolveCommand: async () => null, + }, + paseoConfig: { + scripts: { + proof: { + command: "pwd > workspace-script-output.txt", + }, + }, + }, + workspaceId: "docker-workspace", + projectSlug: "repo", + branchName: null, + scriptName: "proof", + daemonPort: null, + serviceProxy: routeStore, + runtimeStore, + terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords), + }); + + expect(createTerminalCalls).toEqual([ + expect.objectContaining({ + cwd: "/workspace", + command: "/bin/sh", + args: ["-lc", "pwd > workspace-script-output.txt"], + }), + ]); + expect(terminalRecords[0]?.sentInputs).toEqual([]); + }); + + it("keeps selected Local scripts in one reusable persistent shell", async () => { + const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + const createTerminalCalls: CreateTerminalCall[] = []; + const terminalRecords: StubTerminalRecord[] = []; + const terminalManager = createStubTerminalManager(createTerminalCalls, terminalRecords); + const options = { + repoRoot: repoDir, + runtimeCwd: repoDir, + runtime: { + scriptTerminal: { kind: "persistent-shell" as const }, + run: async () => { + throw new Error("unused"); + }, + resolveCommand: async () => null, + }, + paseoConfig: { scripts: { proof: { command: "printf local-script" } } }, + workspaceId: "selected-local-workspace", + projectSlug: "repo", + branchName: "main", + scriptName: "proof", + daemonPort: null, + serviceProxy: routeStore, + runtimeStore, + terminalManager, + }; + + const first = await spawnWorkspaceScript(options); + terminalRecords[0]?.triggerCommandFinished(0); + const second = await spawnWorkspaceScript(options); + + expect(second.terminalId).toBe(first.terminalId); + expect(createTerminalCalls).toHaveLength(1); + expect(createTerminalCalls[0]).not.toHaveProperty("command"); + expect(terminalRecords[0]?.sentInputs).toEqual([ + "printf local-script\r", + "printf local-script\r", + ]); + }); + it("records plain script exit codes from shell command completion without terminal exit", async () => { commitPaseoScripts( { diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 75785057e4..4b1b8c6ed3 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -30,7 +30,11 @@ import { requirePlannedWorkspaceServicePort, refreshWorkspaceServicePort, } from "./workspace-service-port-registry.js"; -import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema"; +import type { + PaseoConfig, + PaseoServicePortAllocation, +} from "@getpaseo/protocol/paseo-config-schema"; +import type { BoundWorkspaceRuntime } from "./workspace-runtime/index.js"; export interface WorktreeBootstrapTerminalResult { name: string | null; @@ -701,6 +705,9 @@ export interface WorktreeScriptResult { export interface SpawnWorkspaceScriptOptions { repoRoot: string; + runtimeCwd?: string; + runtime?: Pick; + paseoConfig?: PaseoConfig; workspaceId: string; projectSlug: string; branchName: string | null; @@ -736,6 +743,7 @@ async function setupServiceScriptRoute(params: { existingRuntimeEntry: ReturnType; serviceProxy: ServiceProxySubsystem; servicePortAllocation: PaseoServicePortAllocation | undefined; + runtime: Pick | undefined; }): Promise { const { scriptConfigs, @@ -751,6 +759,7 @@ async function setupServiceScriptRoute(params: { existingRuntimeEntry, serviceProxy, servicePortAllocation, + runtime, } = params; const serviceDeclarations: Array<{ scriptName: string; port?: number }> = []; @@ -777,6 +786,7 @@ async function setupServiceScriptRoute(params: { workspaceId, branchName, reservedPorts, + runtime, }), }); const port = @@ -792,6 +802,7 @@ async function setupServiceScriptRoute(params: { workspaceId, branchName, reservedPorts, + runtime, }), }) : requirePlannedWorkspaceServicePort(plannedPorts, scriptName); @@ -830,6 +841,9 @@ async function acquireWorkspaceScriptTerminal(params: { existingRuntimeEntry: ReturnType; terminalManager: TerminalManager; repoRoot: string; + runtimeCwd?: string; + scriptTerminal?: BoundWorkspaceRuntime["scriptTerminal"]; + scriptCommand: string; workspaceId: string; scriptName: string; env: Record | undefined; @@ -839,31 +853,60 @@ async function acquireWorkspaceScriptTerminal(params: { existingRuntimeEntry, terminalManager, repoRoot, + runtimeCwd, + scriptTerminal, + scriptCommand, workspaceId, scriptName, env, } = params; let reusableTerminal: TerminalSession | null = null; - if (!serviceScript && existingRuntimeEntry?.terminalId) { + if ( + !serviceScript && + scriptTerminal?.kind !== "direct-command" && + existingRuntimeEntry?.terminalId + ) { reusableTerminal = terminalManager.getTerminal(existingRuntimeEntry.terminalId) ?? null; } const terminal = reusableTerminal ?? (await terminalManager.createTerminal({ cwd: repoRoot, + ...(runtimeCwd ? { cwd: runtimeCwd } : {}), workspaceId, name: scriptName, title: scriptName, env, + ...(scriptTerminal?.kind === "direct-command" + ? { + command: scriptTerminal.command, + args: [...scriptTerminal.argsPrefix, scriptCommand], + } + : {}), })); return { terminal, reusableTerminal }; } +async function launchWorkspaceScriptCommand(params: { + terminal: TerminalSession; + reusableTerminal: TerminalSession | null; + directCommand: boolean; + command: string; +}): Promise { + if (params.directCommand) return; + if (!params.reusableTerminal) { + await waitForTerminalBootstrapReadiness(params.terminal); + } + params.terminal.send({ type: "input", data: `${params.command}\r` }); +} + export async function spawnWorkspaceScript( options: SpawnWorkspaceScriptOptions, ): Promise { const { repoRoot, + runtimeCwd, + paseoConfig, workspaceId, projectSlug, branchName, @@ -878,11 +921,8 @@ export async function spawnWorkspaceScript( logger, onLifecycleChanged, } = options; - const configResult = readPaseoConfig(repoRoot); - if (!configResult.ok) { - throw paseoConfigParseError(configResult); - } - const scriptConfigs = getScriptConfigs(configResult.config); + const resolvedPaseoConfig = resolveWorkspaceScriptConfig(repoRoot, paseoConfig); + const scriptConfigs = getScriptConfigs(resolvedPaseoConfig); const config = scriptConfigs.get(scriptName); if (!config) { throw new Error(`Script '${scriptName}' is not configured in paseo.json`); @@ -917,7 +957,8 @@ export async function spawnWorkspaceScript( serviceProxyPublicBaseUrl, existingRuntimeEntry, serviceProxy, - servicePortAllocation: configResult.config?.worktree?.servicePorts ?? globalServicePorts, + servicePortAllocation: resolvedPaseoConfig?.worktree?.servicePorts ?? globalServicePorts, + runtime: options.runtime, }); hostname = serviceSetup.hostname; port = serviceSetup.port; @@ -930,6 +971,9 @@ export async function spawnWorkspaceScript( existingRuntimeEntry, terminalManager, repoRoot, + runtimeCwd, + scriptTerminal: options.runtime?.scriptTerminal, + scriptCommand: config.command, workspaceId, scriptName, env, @@ -995,10 +1039,12 @@ export async function spawnWorkspaceScript( unsubscribeCommandFinished?.(); }; - if (!reusableTerminal) { - await waitForTerminalBootstrapReadiness(terminal); - } - terminal.send({ type: "input", data: `${config.command}\r` }); + await launchWorkspaceScriptCommand({ + terminal, + reusableTerminal, + directCommand: options.runtime?.scriptTerminal.kind === "direct-command", + command: config.command, + }); logger?.info( { @@ -1044,6 +1090,16 @@ export async function spawnWorkspaceScript( } } +function resolveWorkspaceScriptConfig( + repoRoot: string, + suppliedConfig: PaseoConfig | undefined, +): PaseoConfig | null { + if (suppliedConfig) return suppliedConfig; + const result = readPaseoConfig(repoRoot); + if (!result.ok) throw paseoConfigParseError(result); + return result.config; +} + export function teardownWorktreeScripts(options: { hostnames: string[]; serviceProxy: Pick; diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index 7189d75c28..611398bd90 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -47,6 +47,12 @@ export interface CreateWorktreeCoreResult { created: boolean; } +export interface WorktreeCorePlan { + repoRoot: string; + intent: WorktreeCreationIntent; + worktreeSlug: string; +} + export async function createWorktreeCore( input: CreateWorktreeCoreInput, deps: CreateWorktreeCoreDeps, @@ -58,6 +64,27 @@ async function createWorktreeCoreWithPriority( input: CreateWorktreeCoreInput, deps: CreateWorktreeCoreDeps, ): Promise { + const plan = await planWorktreeCore(input, deps); + const { repoRoot, intent, worktreeSlug: normalizedSlug } = plan; + return { + worktree: await createWorktree({ + cwd: repoRoot, + worktreeSlug: normalizedSlug, + source: intent, + runSetup: input.runSetup ?? true, + paseoHome: input.paseoHome, + worktreesRoot: input.worktreesRoot, + }), + intent, + repoRoot, + created: true, + }; +} + +export async function planWorktreeCore( + input: CreateWorktreeCoreInput, + deps: CreateWorktreeCoreDeps, +): Promise { const repoRoot = await resolveWorktreeRepoRoot(input, deps.workspaceGitService); const requestedWorktreeSlug = input.worktreeSlug ? normalizeWorktreeSlug(input.worktreeSlug) @@ -117,19 +144,7 @@ async function createWorktreeCoreWithPriority( } } - return { - worktree: await createWorktree({ - cwd: repoRoot, - worktreeSlug: normalizedSlug, - source: intent, - runSetup: input.runSetup ?? true, - paseoHome: input.paseoHome, - worktreesRoot: input.worktreesRoot, - }), - intent, - repoRoot, - created: true, - }; + return { repoRoot, intent, worktreeSlug: normalizedSlug }; } async function resolveForge( diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index a5de5baedb..223f5194ad 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -43,6 +43,7 @@ import type { ForgeService } from "../services/forge-service.js"; import { areEquivalentPaths } from "../utils/path.js"; import { createPaseoWorktree as createPaseoWorktreeService, + type CreatePaseoWorktreeDeps, type CreatePaseoWorktreeFn, } from "./paseo-worktree-service.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; @@ -375,6 +376,41 @@ function createPaseoWorktreeForTest(options: { : {}), workspaceGitService, workspaceProvisioning, + workspaceRegistry, + workspaceRuntime: { + create: async (runtimeInput) => { + if (runtimeInput.placement.kind !== "resolved-worktree") { + throw new Error("Expected resolved worktree placement"); + } + const worktree = await createWorktreePrimitive({ + cwd: + runtimeInput.project.source.kind === "host-directory" + ? runtimeInput.project.source.path + : input.cwd, + worktreeSlug: runtimeInput.placement.worktreeSlug, + source: runtimeInput.placement.source, + runSetup: false, + paseoHome: options.paseoHome, + }); + const record = await workspaceRegistry.get(runtimeInput.workspaceId); + if (record) { + await workspaceRegistry.upsert({ + ...record, + cwd: worktree.worktreePath, + hostVisiblePath: worktree.worktreePath, + runtime: { runtimeId: "worktree" }, + }); + } + return { + workspaceId: runtimeInput.workspaceId, + runtimeId: "worktree", + cwd: worktree.worktreePath, + hostVisiblePath: worktree.worktreePath, + materializedFreshContent: true, + }; + }, + destroy: async () => {}, + } as unknown as CreatePaseoWorktreeDeps["workspaceRuntime"], }); }; } @@ -2027,6 +2063,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { requestId: "req-default-scope-sibling", worktreePath: sharedCwd, repoRoot: repoDir, + workspaceId: workspaceA, }, ); diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index cbf9a7f6ee..5643e30bb0 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -26,12 +26,12 @@ import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-sto import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; import { - getWorktreeSetupCommands, resolveWorktreeRuntimeEnv, runWorktreeSetupCommands, slugify, validateBranchSlug, type WorktreeConfig, + type WorktreeSetupExecution, type WorktreeSetupCommandResult, WorktreeSetupError, } from "../utils/worktree.js"; @@ -48,6 +48,7 @@ import { listPaseoWorktreesCommand, } from "./worktree/commands.js"; import type { WorkspaceSetupOperation } from "./workspace-setup-runtime.js"; +import type { BoundWorkspaceRuntime } from "./workspace-runtime/index.js"; const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/-]+$/; @@ -110,6 +111,7 @@ interface CreatePaseoWorktreeInBackgroundDependencies { getDaemonTcpHost: (() => string | null) | null; serviceProxyPublicBaseUrl?: string | null; onScriptsChanged: ((workspaceId: string, workspaceDirectory: string) => void) | null; + bindWorkspaceRuntime?: (workspaceId: string) => Promise; } interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInBackgroundDependencies { @@ -650,6 +652,25 @@ export async function createPaseoWorktreeWorkflow( setupContinuation: { kind: "agent", startAfterAgentCreate: ({ agentId }) => { + if (dependencies.bindWorkspaceRuntime) { + void dependencies + .bindWorkspaceRuntime(workspace.workspaceId) + .then((runtime) => + runWorktreeSetupCommands({ + worktreePath: workspace.cwd, + branchName: workspace.branch ?? "", + cleanupOnFailure: false, + execution: createBoundSetupExecution(runtime), + }), + ) + .catch((error) => { + dependencies.sessionLogger.error( + { err: error, workspaceId: workspace.workspaceId, agentId }, + "Runtime-owned workspace setup failed", + ); + }); + return; + } void runAsyncWorktreeBootstrap({ agentId, workspaceId: workspace.workspaceId, @@ -736,37 +757,47 @@ export async function runWorktreeSetupInBackground( if (!options.shouldBootstrap) { emitSetupProgress("completed", null); + } else if (dependencies.bindWorkspaceRuntime) { + const runtime = await dependencies.bindWorkspaceRuntime(workspaceId); + const workspaceCwd = options.workspaceCwd ?? options.worktreePath; + setupResults = await runWorktreeSetupCommands({ + worktreePath: workspaceCwd, + branchName: worktree.branchName, + cleanupOnFailure: false, + signal, + execution: createBoundSetupExecution(runtime), + onEvent: (event) => { + if (event.type === "command_started") setupStarted = true; + applyWorktreeSetupProgressEvent(progressAccumulator, event); + emitSetupProgress("running", null); + }, + }); + emitSetupProgress("completed", null); } else { const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath; - const setupCommands = getWorktreeSetupCommands(workspaceCwd); - if (setupCommands.length === 0) { - setupStarted = true; - emitSetupProgress("completed", null); - } else { - const runtimeEnv = await resolveWorktreeRuntimeEnv({ - worktreePath: worktree.worktreePath, - branchName: worktree.branchName, - repoRootPath: options.repoRoot, - }); - dependencies.terminalManager?.registerCwdEnv({ - cwd: workspaceCwd, - env: runtimeEnv, - }); - setupStarted = true; - setupResults = await runWorktreeSetupCommands({ - worktreePath: workspaceCwd, - branchName: worktree.branchName, - cleanupOnFailure: false, - repoRootPath: options.repoRoot, - runtimeEnv, - signal, - onEvent: (event) => { - applyWorktreeSetupProgressEvent(progressAccumulator, event); - emitSetupProgress("running", null); - }, - }); - emitSetupProgress("completed", null); - } + const runtimeEnv = await resolveWorktreeRuntimeEnv({ + worktreePath: worktree.worktreePath, + branchName: worktree.branchName, + repoRootPath: options.repoRoot, + }); + dependencies.terminalManager?.registerCwdEnv({ + cwd: workspaceCwd, + env: runtimeEnv, + }); + setupResults = await runWorktreeSetupCommands({ + worktreePath: workspaceCwd, + branchName: worktree.branchName, + cleanupOnFailure: false, + repoRootPath: options.repoRoot, + runtimeEnv, + signal, + onEvent: (event) => { + if (event.type === "command_started") setupStarted = true; + applyWorktreeSetupProgressEvent(progressAccumulator, event); + emitSetupProgress("running", null); + }, + }); + emitSetupProgress("completed", null); } } catch (error) { if (error instanceof WorktreeSetupError) { @@ -796,3 +827,26 @@ export async function runWorktreeSetupInBackground( await dependencies.emitWorkspaceUpdateForWorkspaceId(options.workspaceId); } } + +function createBoundSetupExecution(runtime: BoundWorkspaceRuntime): WorktreeSetupExecution { + return { + async readFile(path) { + const stat = await runtime.files.stat(path); + if (stat.status === "missing") return null; + if (stat.status === "error") throw new Error(stat.error); + const file = await runtime.files.read(path); + const chunks: Buffer[] = []; + for await (const chunk of file.chunks) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks); + }, + resolveCommand: (command) => runtime.resolveCommand(command), + async run(input) { + return runtime.run({ + cwd: ".", + argv: input.argv, + env: input.env, + purpose: { kind: "setup" }, + }); + }, + }; +} diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts index cedc5013c9..60d49bbf8d 100644 --- a/packages/server/src/server/worktree/commands.ts +++ b/packages/server/src/server/worktree/commands.ts @@ -142,6 +142,7 @@ export async function archiveCommand( const result = await archiveByScope(dependencies, { scope: { kind: "worktree", targetPath }, requestId: input.requestId, + releaseBacking: true, }); return { @@ -167,6 +168,7 @@ export async function archiveCommand( const result = await archiveByScope(dependencies, { scope: { kind: "workspace", workspaceId }, requestId: input.requestId, + releaseBacking: true, }); return { diff --git a/packages/server/src/terminal/terminal-manager-factory.ts b/packages/server/src/terminal/terminal-manager-factory.ts index 88dcc2d692..acab080dfe 100644 --- a/packages/server/src/terminal/terminal-manager-factory.ts +++ b/packages/server/src/terminal/terminal-manager-factory.ts @@ -1,8 +1,11 @@ import type { TerminalManager } from "./terminal-manager.js"; +import type { TerminalPtyLaunchInput } from "./terminal.js"; +import type { WorkspaceTerminal } from "../server/workspace-runtime/index.js"; import { createWorkerTerminalManager } from "./worker-terminal-manager.js"; export interface ConfiguredTerminalManagerOptions { getTerminalActivityUrl?: () => string | null; + launchPty?: (input: TerminalPtyLaunchInput) => Promise; } export function createConfiguredTerminalManager( diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index 47905d1424..8fb9bb2eb7 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -36,7 +36,7 @@ afterEach(async () => { for (const terminal of terminalsByCwd.flat()) { await manager.killTerminalAndWait(terminal.id); } - manager.killAll(); + await manager.killAll(); } await new Promise((resolve) => setTimeout(resolve, 50)); while (temporaryDirs.length > 0) { @@ -344,7 +344,7 @@ it("kills all terminals and clears state", async () => { const tmpId = tmpSession.id; const homeId = homeSession.id; - manager.killAll(); + await manager.killAll(); expect(manager.listDirectories()).toEqual([]); expect(manager.getTerminal(tmpId)).toBeUndefined(); diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index df4ced389b..f8f5640e5c 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -4,6 +4,7 @@ import { type TerminalSession, type TerminalStateSnapshot, type TerminalStateSnapshotOptions, + type TerminalPtyLauncher, } from "./terminal.js"; import { captureTerminalLines, type CaptureTerminalLinesResult } from "./terminal-capture.js"; import { randomBytes, randomUUID } from "node:crypto"; @@ -85,7 +86,7 @@ export interface TerminalManager { options?: { start?: number; end?: number; stripAnsi?: boolean }, ): Promise; listDirectories(): string[]; - killAll(): void; + killAll(): Promise; subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void; subscribeTerminalActivity(listener: TerminalActivityListener): () => void; subscribeTerminalWorkspaceContributionChanged( @@ -95,6 +96,7 @@ export interface TerminalManager { export interface TerminalManagerOptions { getTerminalActivityUrl?: () => string | null; + resolvePtyLauncher?: (terminalId: string) => TerminalPtyLauncher | undefined; } function createActivityToken(): string { @@ -341,6 +343,7 @@ export function createTerminalManager( ...(terminalActivityUrl ? { PASEO_TERMINAL_ACTIVITY_URL: terminalActivityUrl } : {}), }; terminalActivityTokenById.set(terminalId, activityToken); + const launchPty = managerOptions.resolvePtyLauncher?.(terminalId); let session: TerminalSession; try { session = registerSession( @@ -356,6 +359,7 @@ export function createTerminalManager( ...(options.cols !== undefined ? { cols: options.cols } : {}), ...(mergedEnv ? { env: mergedEnv } : {}), activityEnv, + ...(launchPty ? { launchPty } : {}), }), ); } catch (error) { @@ -463,10 +467,14 @@ export function createTerminalManager( return Array.from(terminalsByCwd.keys()); }, - killAll(): void { - for (const id of Array.from(terminalsById.keys())) { - removeSessionById(id, { kill: true }); - } + async killAll(): Promise { + const sessions = Array.from(terminalsById.values()); + for (const session of sessions) removeSessionById(session.id, { kill: false }); + await Promise.all( + sessions.map((session) => + session.killAndWait({ gracefulTimeoutMs: 500, forceTimeoutMs: 500 }), + ), + ); }, subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void { diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index e2e07ce1d3..b74bf29d29 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -262,6 +262,61 @@ describe("terminal-session-controller legacy terminal creation", () => { ]); }); + test("never selects a runtime terminal from compatibility cwd", async () => { + const cwd = "/workspace"; + const outboundMessages: SessionOutboundMessage[] = []; + const createTerminal = vi.fn(); + const terminalManager: TerminalManager = { + getTerminals: vi.fn(), + createTerminal, + registerCwdEnv: vi.fn(), + validateTerminalActivityToken: vi.fn(() => "unknown"), + getTerminal: vi.fn(), + getTerminalState: vi.fn(), + setTerminalTitle: vi.fn(), + setTerminalActivity: vi.fn(), + clearTerminalAttention: vi.fn(), + killTerminal: vi.fn(), + killTerminalAndWait: vi.fn(), + captureTerminal: vi.fn(), + listDirectories: vi.fn(() => []), + killAll: vi.fn(), + subscribeTerminalsChanged: vi.fn(() => vi.fn()), + subscribeTerminalActivity: vi.fn(() => vi.fn()), + subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), + }; + const controller = new TerminalSessionController({ + terminalManager, + emit: (message) => outboundMessages.push(message), + emitBinary: vi.fn(), + hasBinaryChannel: () => true, + isPathWithinRoot: isSameOrDescendantPath, + sessionLogger: createLogger(), + listTerminalWorkspaceRefs: async () => [ + { workspaceId: "docker-a", cwd, runtimeId: "docker" }, + { workspaceId: "docker-b", cwd, runtimeId: "docker" }, + ], + }); + + await controller.dispatch({ + type: "create_terminal_request", + cwd, + requestId: "selected-without-id", + }); + + expect(createTerminal).not.toHaveBeenCalled(); + expect(outboundMessages).toEqual([ + { + type: "create_terminal_response", + payload: { + terminal: null, + error: "workspaceId is required", + requestId: "selected-without-id", + }, + }, + ]); + }); + test("forwards the client-provided viewport size to the terminal manager", async () => { const outboundMessages: SessionOutboundMessage[] = []; const createTerminal = vi.fn( diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 05d544dcc2..e4c94a6ff0 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -88,6 +88,7 @@ export interface TerminalSessionControllerOptions { interface TerminalWorkspaceRef { workspaceId: string; cwd: string; + runtimeId?: string | null; } export interface TerminalSessionControllerMetrics { @@ -584,7 +585,9 @@ export class TerminalSessionController { } private async resolveLegacyTerminalWorkspaceId(cwd: string): Promise { - const workspaceRefs = await this.listTerminalWorkspaceRefs(); + const workspaceRefs = (await this.listTerminalWorkspaceRefs()).filter( + (workspace) => workspace.runtimeId == null, + ); if (workspaceRefs.length === 0) { return null; } diff --git a/packages/server/src/terminal/terminal-worker-process.ts b/packages/server/src/terminal/terminal-worker-process.ts index 4e16f8de04..1449ee2f65 100644 --- a/packages/server/src/terminal/terminal-worker-process.ts +++ b/packages/server/src/terminal/terminal-worker-process.ts @@ -1,17 +1,33 @@ import { createTerminalManager } from "./terminal-manager.js"; +import { randomUUID } from "node:crypto"; +import { constants } from "node:os"; import { captureTerminalLines } from "./terminal-capture.js"; import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js"; -import type { TerminalSession, TerminalStateSnapshotOptions } from "./terminal.js"; +import type { + TerminalPtyProcess, + TerminalPtyLaunchInput, + TerminalSession, + TerminalStateSnapshotOptions, +} from "./terminal.js"; import type { TerminalWorkerRequest, + TerminalWorkerParentMessage, + TerminalWorkerPtyEvent, + TerminalWorkerMessage, TerminalWorkerStateResult, - TerminalWorkerToParentMessage, WorkerTerminalInfo, } from "./terminal-worker-protocol.js"; type TerminalCreateRequest = Extract; -const manager = createTerminalManager(); +const ptyProcesses = new Map(); +const pendingPtySpawns = new Map< + string, + { resolve: (process: TerminalPtyProcess | null) => void; reject: (error: Error) => void } +>(); +const manager = createTerminalManager({ + resolvePtyLauncher: () => launchRuntimePty, +}); const unsubscribeByTerminalId = new Map void>>(); const outputCoalescerByTerminalId = new Map(); let ipcClosing = false; @@ -36,19 +52,131 @@ process.on("uncaughtException", (error) => { reportInFlightTerminalCreateFailure(error); }); -function sendToParent(message: TerminalWorkerToParentMessage): void { +function sendToParent(message: TerminalWorkerMessage): void { if (ipcClosing || !process.connected || !process.send) { + closePtyBridge(new Error("Terminal parent IPC is not connected")); return; } try { process.send(message, (error) => { - if (error) { - ipcClosing = true; - } + if (error) closePtyBridge(error); }); - } catch { - ipcClosing = true; + } catch (error) { + closePtyBridge(error); + } +} + +function closePtyBridge(error: unknown): void { + ipcClosing = true; + const failure = error instanceof Error ? error : new Error(String(error)); + for (const pending of pendingPtySpawns.values()) pending.reject(failure); + pendingPtySpawns.clear(); + for (const proxy of ptyProcesses.values()) { + proxy.emitExit({ code: 1, signal: null }); + } + ptyProcesses.clear(); +} + +interface RuntimePtyProxy extends TerminalPtyProcess { + emitData(data: string): void; + emitExit(exit: { code: number | null; signal: NodeJS.Signals | null }): void; +} + +function launchRuntimePty( + input: TerminalPtyLaunchInput, +): TerminalPtyProcess | null | Promise { + return new Promise((resolve, reject) => { + const processId = randomUUID(); + pendingPtySpawns.set(processId, { resolve, reject }); + sendToParent({ type: "ptySpawn", processId, input }); + }); +} + +function createRuntimePtyProxy(processId: string): RuntimePtyProxy { + const dataListeners = new Set<(data: string) => void>(); + const exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>(); + let pendingData = ""; + let pendingExit: { exitCode: number; signal?: number } | null = null; + return { + pid: -1, + write(data) { + sendToParent({ type: "ptyWrite", processId, data }); + }, + resize(cols, rows) { + sendToParent({ type: "ptyResize", processId, cols, rows }); + }, + kill(signal) { + sendToParent({ type: "ptyKill", processId, signal }); + }, + onData(listener) { + dataListeners.add(listener); + if (pendingData) { + const data = pendingData; + pendingData = ""; + listener(data); + } + return { dispose: () => dataListeners.delete(listener) }; + }, + onExit(listener) { + exitListeners.add(listener); + const exit = pendingExit; + if (exit) queueMicrotask(() => listener(exit)); + return { dispose: () => exitListeners.delete(listener) }; + }, + emitData(data) { + if (dataListeners.size === 0) { + pendingData += data; + return; + } + for (const listener of dataListeners) listener(data); + }, + emitExit(exit) { + const signal = exit.signal ? constants.signals[exit.signal] : undefined; + const event = { exitCode: exit.code ?? 0, signal }; + if (exitListeners.size === 0) pendingExit = event; + else for (const listener of exitListeners) listener(event); + dataListeners.clear(); + if (exitListeners.size > 0) exitListeners.clear(); + }, + }; +} + +function handlePtyEvent(message: TerminalWorkerPtyEvent): void { + if (message.type === "ptySpawnResult") { + const pending = pendingPtySpawns.get(message.processId); + if (!pending) return; + pendingPtySpawns.delete(message.processId); + if (message.mode === "legacy") { + pending.resolve(null); + return; + } + const proxy = createRuntimePtyProxy(message.processId); + ptyProcesses.set(message.processId, proxy); + pending.resolve(proxy); + return; + } + if (message.type === "ptySpawnError") { + const pending = pendingPtySpawns.get(message.processId); + pendingPtySpawns.delete(message.processId); + pending?.reject(new Error(message.error)); + return; } + const proxy = ptyProcesses.get(message.processId); + if (!proxy) return; + if (message.type === "ptyData") proxy.emitData(message.data); + else { + ptyProcesses.delete(message.processId); + setImmediate(() => proxy.emitExit(message.exit)); + } +} + +function isPtyEvent(message: TerminalWorkerParentMessage): message is TerminalWorkerPtyEvent { + return ( + message.type === "ptySpawnResult" || + message.type === "ptySpawnError" || + message.type === "ptyData" || + message.type === "ptyExit" + ); } function buildTerminalStateResult( @@ -316,7 +444,7 @@ async function handleRequest(message: TerminalWorkerRequest): Promise { } case "killAll": { - manager.killAll(); + await manager.killAll(); for (const terminalId of Array.from(unsubscribeByTerminalId.keys())) { clearTerminalSubscriptions(terminalId); } @@ -333,7 +461,11 @@ async function handleRequest(message: TerminalWorkerRequest): Promise { } } -process.on("message", (message: TerminalWorkerRequest) => { +process.on("message", (message: TerminalWorkerParentMessage) => { + if (isPtyEvent(message)) { + handlePtyEvent(message); + return; + } void handleRequest(message).catch((error: unknown) => { sendToParent({ type: "response", @@ -345,6 +477,6 @@ process.on("message", (message: TerminalWorkerRequest) => { }); process.once("disconnect", () => { - ipcClosing = true; - manager.killAll(); + closePtyBridge(new Error("Terminal parent IPC disconnected")); + void manager.killAll(); }); diff --git a/packages/server/src/terminal/terminal-worker-protocol.ts b/packages/server/src/terminal/terminal-worker-protocol.ts index 6b722c978a..97310159d9 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.ts @@ -8,6 +8,7 @@ import type { import type { TerminalState } from "@getpaseo/protocol/messages"; import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; import type { CaptureTerminalLinesResult } from "./terminal-capture.js"; +import type { TerminalPtyLaunchInput } from "./terminal.js"; export interface WorkerTerminalInfo { id: string; @@ -97,6 +98,18 @@ export type TerminalWorkerRequest = message: ClientMessage; }; +export type TerminalWorkerPtyEvent = + | { type: "ptySpawnResult"; processId: string; mode: "runtime" | "legacy" } + | { type: "ptySpawnError"; processId: string; error: string } + | { type: "ptyData"; processId: string; data: string } + | { + type: "ptyExit"; + processId: string; + exit: { code: number | null; signal: NodeJS.Signals | null }; + }; + +export type TerminalWorkerParentMessage = TerminalWorkerRequest | TerminalWorkerPtyEvent; + export type TerminalWorkerResponse = | { type: "response"; @@ -148,6 +161,14 @@ export type TerminalWorkerEvent = export type TerminalWorkerToParentMessage = TerminalWorkerResponse | TerminalWorkerEvent; +export type TerminalWorkerPtyCommand = + | { type: "ptySpawn"; processId: string; input: TerminalPtyLaunchInput } + | { type: "ptyWrite"; processId: string; data: string } + | { type: "ptyResize"; processId: string; cols: number; rows: number } + | { type: "ptyKill"; processId: string; signal?: NodeJS.Signals }; + +export type TerminalWorkerMessage = TerminalWorkerToParentMessage | TerminalWorkerPtyCommand; + export type TerminalWorkerCaptureResult = CaptureTerminalLinesResult; // The worker fills TerminalStateSnapshot.replayPreamble on getTerminalState so // the parent can cache the input-mode preamble instead of re-deriving it. diff --git a/packages/server/src/terminal/terminal-workspace-runtime.posix.test.ts b/packages/server/src/terminal/terminal-workspace-runtime.posix.test.ts new file mode 100644 index 0000000000..b332c3215c --- /dev/null +++ b/packages/server/src/terminal/terminal-workspace-runtime.posix.test.ts @@ -0,0 +1,394 @@ +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { createWorkspaceRuntimeService } from "../server/workspace-runtime/index.js"; +import { createConfiguredTerminalManager } from "./terminal-manager-factory.js"; +import type { TerminalManager } from "./terminal-manager.js"; + +const posixDescribe = describe.runIf(process.platform !== "win32"); +const cleanup: Array<{ root: string; manager: TerminalManager }> = []; + +afterEach(async () => { + await Promise.all( + cleanup.splice(0).map(async ({ root, manager }) => { + await manager.killAll(); + await rm(root, { recursive: true, force: true }); + }), + ); +}); + +posixDescribe("workspace-bound terminal manager", () => { + test("keeps terminal state in Paseo while the selected runtime owns the PTY", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-terminal-runtime-")); + const cwd = path.join(root, "workspace"); + await mkdir(cwd); + const runtimeIds = new Map(); + const runtime = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => { + runtimeIds.set(workspaceId, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, + }); + await runtime.create({ + workspaceId: "selected-workspace", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: cwd } }, + placement: { kind: "existing" }, + }); + const silentShell = path.join(root, "silent-shell"); + const silentShellReady = path.join(root, "silent-shell-ready"); + await writeFile( + silentShell, + `#!/usr/bin/env node\nprocess.stdin.setRawMode(true);process.stdin.setEncoding('utf8');require('node:fs').writeFileSync(${JSON.stringify(silentShellReady)},'ready');process.stdin.once('data',data=>{process.stdout.write(\`ready|λ|\${data.trim()}|\${process.stdout.columns}x\${process.stdout.rows}\`);process.exit(4)});\n`, + ); + await chmod(silentShell, 0o755); + const originalShell = process.env.SHELL; + process.env.SHELL = silentShell; + const manager = createConfiguredTerminalManager({ + launchPty: async (input) => + runtime.openTerminal({ + workspaceId: input.workspaceId, + argv: input.argv, + env: input.env, + purpose: { kind: "terminal", terminalId: input.terminalId }, + rows: input.rows, + cols: input.cols, + term: input.term, + }), + }); + if (originalShell === undefined) delete process.env.SHELL; + else process.env.SHELL = originalShell; + cleanup.push({ root, manager }); + + const terminal = await manager.createTerminal({ + workspaceId: "selected-workspace", + cwd, + rows: 24, + cols: 80, + }); + let output = ""; + terminal.subscribe((message) => { + if (message.type === "output") output += message.data; + }); + await vi.waitFor(async () => expect(await readFile(silentShellReady, "utf8")).toBe("ready")); + terminal.send({ type: "resize", rows: 35, cols: 97 }); + terminal.send({ type: "input", data: "héllo\n" }); + await vi.waitFor(() => expect(output).toContain("ready|λ|héllo|97x35")); + await vi.waitFor(() => expect(manager.getTerminal(terminal.id)).toBeUndefined()); + }); + + test("a selected runtime failure never falls back to a host PTY", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-terminal-runtime-failure-")); + const manager = createConfiguredTerminalManager({ + launchPty: async () => { + throw new Error("selected runtime PTY unavailable"); + }, + }); + cleanup.push({ root, manager }); + await expect(manager.createTerminal({ workspaceId: "selected", cwd: root })).rejects.toThrow( + "selected runtime PTY unavailable", + ); + }); + + test("forwards initial runtime input before a following resize without a readiness timer", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-terminal-immediate-input-")); + let input = ""; + let observeResize!: () => void; + const resized = new Promise((resolve) => { + observeResize = resolve; + }); + let finish!: () => void; + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve) => { + finish = () => resolve({ code: null, signal: "SIGTERM" }); + }, + ); + const manager = createConfiguredTerminalManager({ + launchPty: async () => ({ + onData: () => () => {}, + write: (data) => { + input += data; + }, + resize: observeResize, + exited, + kill: finish, + }), + }); + cleanup.push({ root, manager }); + const terminal = await manager.createTerminal({ + workspaceId: "selected-workspace", + cwd: root, + command: process.execPath, + args: ["-e", "setInterval(()=>{},1000)"], + }); + + terminal.send({ type: "input", data: "first-input" }); + terminal.send({ type: "resize", rows: 35, cols: 97 }); + await resized; + + expect(input).toBe("first-input"); + }); + + test("an unexpected terminal-worker exit terminates its parent-owned runtime PTY", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-terminal-worker-exit-")); + const cwd = path.join(root, "workspace"); + const pidFile = path.join(root, "runtime-pty.pid"); + await mkdir(cwd); + const runtimeIds = new Map(); + const runtime = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => { + runtimeIds.set(workspaceId, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, + }); + await runtime.create({ + workspaceId: "selected-workspace", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: cwd } }, + placement: { kind: "existing" }, + }); + const manager = createConfiguredTerminalManager({ + launchPty: async (input) => { + if (input.workspaceId === "legacy-crasher") return null; + return runtime.openTerminal({ + workspaceId: input.workspaceId, + argv: input.argv, + env: input.env, + purpose: { kind: "terminal", terminalId: input.terminalId }, + rows: input.rows, + cols: input.cols, + term: input.term, + }); + }, + }); + cleanup.push({ root, manager }); + + try { + await manager.createTerminal({ + workspaceId: "selected-workspace", + cwd, + command: process.execPath, + args: [ + "-e", + `require('node:fs').writeFileSync(${JSON.stringify(pidFile)},String(process.pid));process.on('SIGTERM',()=>{});setInterval(()=>{},1000)`, + ], + }); + await vi.waitFor(async () => + expect(processExists(Number(await readFile(pidFile, "utf8")))).toBe(true), + ); + + await expect( + manager.createTerminal({ + workspaceId: "legacy-crasher", + cwd, + command: process.execPath, + args: ["-e", "process.kill(process.ppid, 'SIGKILL')"], + }), + ).rejects.toThrow("Terminal worker exited"); + + await vi.waitFor( + async () => expect(processExists(Number(await readFile(pidFile, "utf8")))).toBe(false), + { timeout: 5_000 }, + ); + } finally { + await runtime.destroy("selected-workspace"); + } + }, 15_000); + + test("killAll waits for parent-owned runtime PTYs to terminate", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-terminal-kill-all-")); + const cwd = path.join(root, "workspace"); + const pidFile = path.join(root, "runtime-pty.pid"); + await mkdir(cwd); + const runtimeIds = new Map(); + const runtime = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => { + runtimeIds.set(workspaceId, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, + }); + await runtime.create({ + workspaceId: "selected-workspace", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: cwd } }, + placement: { kind: "existing" }, + }); + const manager = createConfiguredTerminalManager({ + launchPty: async (input) => + runtime.openTerminal({ + workspaceId: input.workspaceId, + argv: input.argv, + env: input.env, + purpose: { kind: "terminal", terminalId: input.terminalId }, + rows: input.rows, + cols: input.cols, + term: input.term, + }), + }); + + try { + await manager.createTerminal({ + workspaceId: "selected-workspace", + cwd, + command: process.execPath, + args: [ + "-e", + `require('node:fs').writeFileSync(${JSON.stringify(pidFile)},String(process.pid));process.on('SIGTERM',()=>{});setInterval(()=>{},1000)`, + ], + }); + await vi.waitFor(async () => + expect(processExists(Number(await readFile(pidFile, "utf8")))).toBe(true), + ); + + await manager.killAll(); + + expect(processExists(Number(await readFile(pidFile, "utf8")))).toBe(false); + } finally { + await runtime.destroy("selected-workspace"); + await rm(root, { recursive: true, force: true }); + } + }, 15_000); + + test("a hung runtime PTY launch cannot block cleanup of an already-owned PTY", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-terminal-hung-launch-")); + const cwd = path.join(root, "workspace"); + const pidFile = path.join(root, "runtime-pty.pid"); + await mkdir(cwd); + const runtimeIds = new Map(); + const runtime = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => { + runtimeIds.set(workspaceId, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, + }); + await runtime.create({ + workspaceId: "selected-workspace", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: cwd } }, + placement: { kind: "existing" }, + }); + let markHungLaunchStarted!: () => void; + const hungLaunchStarted = new Promise((resolve) => { + markHungLaunchStarted = resolve; + }); + const manager = createConfiguredTerminalManager({ + launchPty: async (input) => { + if (input.workspaceId === "hung-workspace") { + markHungLaunchStarted(); + return new Promise(() => {}); + } + if (input.workspaceId === "legacy-crasher") return null; + return runtime.openTerminal({ + workspaceId: input.workspaceId, + argv: input.argv, + env: input.env, + purpose: { kind: "terminal", terminalId: input.terminalId }, + rows: input.rows, + cols: input.cols, + term: input.term, + }); + }, + }); + + try { + await manager.createTerminal({ + workspaceId: "selected-workspace", + cwd, + command: process.execPath, + args: [ + "-e", + `require('node:fs').writeFileSync(${JSON.stringify(pidFile)},String(process.pid));process.on('SIGTERM',()=>{});setInterval(()=>{},1000)`, + ], + }); + await vi.waitFor(async () => + expect(processExists(Number(await readFile(pidFile, "utf8")))).toBe(true), + ); + + await manager.createTerminal({ + workspaceId: "legacy-crasher", + cwd, + command: process.execPath, + args: ["-e", "setTimeout(()=>process.kill(process.ppid, 'SIGKILL'),500)"], + }); + + const hungCreate = manager.createTerminal({ + workspaceId: "hung-workspace", + cwd, + command: process.execPath, + args: ["-e", "setInterval(()=>{},1000)"], + }); + const hungCreateResult = hungCreate.then( + () => null, + (error: unknown) => error, + ); + await hungLaunchStarted; + const hungCreateError = await hungCreateResult; + expect(hungCreateError).toBeInstanceOf(Error); + expect((hungCreateError as Error).message).toContain("Terminal worker exited"); + await manager.killAll(); + + expect(processExists(Number(await readFile(pidFile, "utf8")))).toBe(false); + } finally { + await runtime.destroy("selected-workspace"); + await rm(root, { recursive: true, force: true }); + } + }, 15_000); + + test("killAll reports a runtime PTY that remains alive after SIGKILL", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-terminal-stuck-cleanup-")); + const manager = createConfiguredTerminalManager({ + launchPty: async () => ({ + onData: () => () => {}, + write: () => {}, + resize: () => {}, + exited: new Promise(() => {}), + kill: () => {}, + }), + }); + + try { + await manager.createTerminal({ + workspaceId: "selected-workspace", + cwd: root, + command: process.execPath, + args: ["-e", "setInterval(()=>{},1000)"], + }); + + await expect(manager.killAll()).rejects.toThrow("Runtime PTY remained alive after SIGKILL"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, 10_000); +}); + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } +} diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index b9d1b74017..9e38ce91b7 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -128,6 +128,74 @@ export interface CreateTerminalOptions { title?: string; command?: string; args?: string[]; + launchPty?: TerminalPtyLauncher; +} + +export interface TerminalPtyProcess { + pid: number; + write(data: string): void; + resize(cols: number, rows: number): void; + kill(signal?: NodeJS.Signals): void; + onData(listener: (data: string) => void): { dispose(): void }; + onExit(listener: (event: { exitCode: number; signal?: number }) => void): { dispose(): void }; +} + +export interface TerminalPtyLaunchInput { + terminalId: string; + workspaceId: string; + cwd: string; + argv: readonly [string, ...string[]]; + env: Record; + rows: number; + cols: number; + term: string; +} + +export type TerminalPtyLauncher = ( + input: TerminalPtyLaunchInput, +) => TerminalPtyProcess | null | Promise; + +async function launchTerminalPty(input: { + launchPty: TerminalPtyLauncher; + terminalId: string; + workspaceId: string; + cwd: string; + command: string; + args: string[] | string; + env: Record; + rows: number; + cols: number; +}): Promise { + const runtimePty = await input.launchPty({ + terminalId: input.terminalId, + workspaceId: input.workspaceId, + cwd: input.cwd, + argv: [input.command, ...(Array.isArray(input.args) ? input.args : [input.args])], + env: input.env, + rows: input.rows, + cols: input.cols, + term: "xterm-256color", + }); + if (runtimePty) return runtimePty; + return launchLegacyTerminalPty(input); +} + +function launchLegacyTerminalPty(input: { + cwd: string; + command: string; + args: string[] | string; + env: Record; + rows: number; + cols: number; +}): TerminalPtyProcess { + ensureNodePtySpawnHelperExecutableForCurrentPlatform(); + return pty.spawn(input.command, input.args, { + name: "xterm-256color", + cols: input.cols, + rows: input.rows, + cwd: input.cwd, + env: input.env, + }); } function toTerminalActivity(snapshot: { @@ -935,26 +1003,38 @@ export async function createTerminal(options: CreateTerminalOptions): Promise void): boolean { this.sentMessages.push(message); callback(null); + if (message.type === "killAll") { + queueMicrotask(() => + this.emitWorkerMessage({ type: "response", requestId: message.requestId, ok: true }), + ); + } return true; } @@ -150,7 +155,7 @@ afterEach(async () => { .catch(() => {}), ), ); - manager?.killAll(); + await manager?.killAll(); manager = null; while (temporaryDirs.length > 0) { const dir = temporaryDirs.pop(); @@ -497,7 +502,11 @@ it("injects parent-minted terminal activity env through the worker", async () => }); it("starts the default shell through the worker and accepts quoted commands", async () => { + const originalShell = process.env.SHELL; + if (!isPlatform("win32")) process.env.SHELL = "/bin/sh"; manager = createWorkerTerminalManager(); + if (originalShell === undefined) delete process.env.SHELL; + else process.env.SHELL = originalShell; const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-shell-")); temporaryDirs.push(cwd); const markerPath = join(cwd, "shell quoted marker.txt"); diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index 0f0243d7a8..7d9ed3a5da 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -12,6 +12,7 @@ import type { TerminalCommandFinishedInfo, TerminalExitInfo, TerminalSession, + TerminalPtyLaunchInput, TerminalStateSnapshot, } from "./terminal.js"; import type { CaptureTerminalLinesResult } from "./terminal-capture.js"; @@ -27,14 +28,19 @@ import type { } from "./terminal-manager.js"; import type { TerminalWorkerRequest, + TerminalWorkerParentMessage, TerminalWorkerResponse, TerminalWorkerToParentMessage, + TerminalWorkerMessage, + TerminalWorkerPtyCommand, WorkerCreateTerminalOptions, TerminalWorkerStateResult, WorkerTerminalInfo, } from "./terminal-worker-protocol.js"; const REQUEST_TIMEOUT_MS = 10000; +const RUNTIME_PTY_TERM_TIMEOUT_MS = 500; +const RUNTIME_PTY_KILL_TIMEOUT_MS = 500; type RequiredWorkerTerminalInfo = WorkerTerminalInfo & { workspaceId: string }; @@ -82,10 +88,10 @@ interface WorkerTerminalRecord { interface TerminalWorkerProcess { connected: boolean; killed: boolean; - send(message: TerminalWorkerRequest, callback: (error: Error | null) => void): boolean; + send(message: TerminalWorkerParentMessage, callback: (error: Error | null) => void): boolean; disconnect(): void; kill(): boolean; - on(event: "message", listener: (message: TerminalWorkerToParentMessage) => void): this; + on(event: "message", listener: (message: TerminalWorkerMessage) => void): this; on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; } @@ -93,8 +99,21 @@ interface WorkerTerminalManagerOptions { requestTimeoutMs?: number; forkWorker?: () => TerminalWorkerProcess; getTerminalActivityUrl?: () => string | null; + launchPty?: RuntimeTerminalLauncher; } +interface RuntimeTerminalProcess { + onData(listener: (data: string) => void): () => void; + write(data: string): void; + resize(cols: number, rows: number): void; + exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>; + kill(signal?: NodeJS.Signals): void; +} + +type RuntimeTerminalLauncher = ( + input: TerminalPtyLaunchInput, +) => Promise; + function createActivityToken(): string { return randomBytes(32).toString("base64url"); } @@ -128,6 +147,22 @@ function isResponse(message: TerminalWorkerToParentMessage): message is Terminal return message.type === "response"; } +function isPtyCommand(message: TerminalWorkerMessage): message is TerminalWorkerPtyCommand { + return ( + message.type === "ptySpawn" || + message.type === "ptyWrite" || + message.type === "ptyResize" || + message.type === "ptyKill" + ); +} + +function capturePromiseError(promise: Promise): Promise { + return promise.then( + () => null, + (error: unknown) => error, + ); +} + function cloneTerminalInfo(info: RequiredWorkerTerminalInfo): RequiredWorkerTerminalInfo { return { id: info.id, @@ -153,6 +188,7 @@ export function createWorkerTerminalManager( const worker = managerOptions.forkWorker ? managerOptions.forkWorker() : forkTerminalWorker(); const requestTimeoutMs = managerOptions.requestTimeoutMs ?? REQUEST_TIMEOUT_MS; const pendingRequests = new Map(); + const runtimePtys = new Map(); const recordsById = new Map(); const terminalIdsByCwd = new Map>(); const terminalActivityTokenById = new Map(); @@ -161,7 +197,9 @@ export function createWorkerTerminalManager( const terminalWorkspaceContributionChangedListeners = new Set(); let workerExited = false; + let shuttingDown = false; let workerShutdownTimer: ReturnType | null = null; + let workerFailureCleanup: Promise | null = null; function emitTerminalsChanged(event: TerminalsChangedEvent): void { for (const listener of Array.from(terminalsChangedListeners)) { @@ -588,7 +626,11 @@ export function createWorkerTerminalManager( } } - worker.on("message", (message: TerminalWorkerToParentMessage) => { + worker.on("message", (message: TerminalWorkerMessage) => { + if (isPtyCommand(message)) { + handlePtyCommand(message); + return; + } if (isResponse(message)) { const pending = pendingRequests.get(message.requestId); if (!pending) { @@ -606,13 +648,180 @@ export function createWorkerTerminalManager( handleWorkerEvent(message); }); - worker.on("exit", (code, signal) => { + function handlePtyCommand(message: TerminalWorkerPtyCommand): void { + if (message.type === "ptySpawn") { + void launchRuntimePty(message); + return; + } + const process = runtimePtys.get(message.processId); + if (!process) return; + if (message.type === "ptyWrite") process.write(message.data); + else if (message.type === "ptyResize") process.resize(message.cols, message.rows); + else process.kill(message.signal); + } + + async function launchRuntimePty( + message: Extract, + ): Promise { + try { + const process = (await managerOptions.launchPty?.(message.input)) ?? null; + if (!process) { + await sendWorkerMessage({ + type: "ptySpawnResult", + processId: message.processId, + mode: "legacy", + }); + return; + } + if (workerExited || shuttingDown) { + await terminateRuntimePty(process); + return; + } + runtimePtys.set(message.processId, process); + try { + await sendWorkerMessage({ + type: "ptySpawnResult", + processId: message.processId, + mode: "runtime", + }); + } catch (error) { + runtimePtys.delete(message.processId); + await terminateRuntimePty(process); + beginWorkerFailure(error); + return; + } + process.onData((data) => { + void sendWorkerMessage({ type: "ptyData", processId: message.processId, data }).catch( + beginWorkerFailure, + ); + }); + void observeRuntimePtyExit(message.processId, process); + } catch (error) { + void sendWorkerMessage({ + type: "ptySpawnError", + processId: message.processId, + error: error instanceof Error ? error.message : String(error), + }).catch(beginWorkerFailure); + } + } + + async function observeRuntimePtyExit( + processId: string, + process: RuntimeTerminalProcess, + ): Promise { + try { + const exit = await process.exited; + runtimePtys.delete(processId); + await sendWorkerMessage({ type: "ptyExit", processId, exit }); + } catch (error) { + runtimePtys.delete(processId); + try { + await sendWorkerMessage({ + type: "ptyData", + processId, + data: `${error instanceof Error ? error.message : String(error)}\r\n`, + }); + await sendWorkerMessage({ + type: "ptyExit", + processId, + exit: { code: 1, signal: null }, + }); + } catch (sendError) { + beginWorkerFailure(sendError); + } + } + } + + function sendWorkerMessage(message: TerminalWorkerParentMessage): Promise { + if (workerExited || !worker.connected) { + return Promise.reject(new Error("Terminal worker is not running")); + } + return new Promise((resolve, reject) => { + try { + worker.send(message, (error) => { + if (error) reject(error); + else resolve(); + }); + } catch (error) { + reject(error); + } + }); + } + + function beginWorkerFailure(error: unknown): void { + if (workerFailureCleanup) return; workerExited = true; + const failure = error instanceof Error ? error : new Error(String(error)); + rejectPendingRequests(failure); + for (const terminalId of Array.from(recordsById.keys())) removeRecord(terminalId); + workerFailureCleanup = capturePromiseError(cleanupAfterWorkerFailure()); + } + + async function cleanupAfterWorkerFailure(): Promise { + try { + await terminateRuntimePtys(); + } finally { + if (worker.connected) { + try { + worker.disconnect(); + } catch { + // The worker can exit between the connected check and disconnect. + } + } + } + } + + async function terminateRuntimePtys(): Promise { + const processes = Array.from(runtimePtys.values()); + runtimePtys.clear(); + const results = await Promise.allSettled(processes.map(terminateRuntimePty)); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (failures.length > 0) { + const details = failures + .map((failure) => (failure instanceof Error ? failure.message : String(failure))) + .join("; "); + throw new AggregateError(failures, `Failed to terminate runtime PTYs: ${details}`); + } + } + + async function terminateRuntimePty(process: RuntimeTerminalProcess): Promise { + try { + process.kill("SIGTERM"); + } catch { + // The process can exit between the state check and the signal. + } + if (await waitForRuntimePtyExit(process, RUNTIME_PTY_TERM_TIMEOUT_MS)) return; + try { + process.kill("SIGKILL"); + } catch { + // The process can exit between the timeout and the signal. + } + if (!(await waitForRuntimePtyExit(process, RUNTIME_PTY_KILL_TIMEOUT_MS))) { + throw new Error("Runtime PTY remained alive after SIGKILL"); + } + } + + async function waitForRuntimePtyExit( + process: RuntimeTerminalProcess, + timeoutMs: number, + ): Promise { + return Promise.race([ + process.exited.then( + () => true, + () => true, + ), + new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), + ]); + } + + worker.on("exit", (code, signal) => { if (workerShutdownTimer) { clearTimeout(workerShutdownTimer); workerShutdownTimer = null; } - rejectPendingRequests(new Error(`Terminal worker exited (${signal ?? code ?? "unknown"})`)); + beginWorkerFailure(new Error(`Terminal worker exited (${signal ?? code ?? "unknown"})`)); }); function sendRequest(input: TerminalWorkerRequestInput): Promise { @@ -813,23 +1022,45 @@ export function createWorkerTerminalManager( return Array.from(terminalIdsByCwd.keys()); }, - killAll(): void { - void sendRequest({ type: "killAll" }) - .catch(() => { - // no-op - }) - .finally(() => { - if (worker.connected) { - worker.disconnect(); - } - if (!worker.killed && !workerShutdownTimer) { - workerShutdownTimer = setTimeout(() => { - worker.kill(); - }, 1000); - } - }); - for (const terminalId of Array.from(recordsById.keys())) { - removeRecord(terminalId); + async killAll(): Promise { + shuttingDown = true; + let requestError: unknown; + let cleanupError: unknown; + try { + if (!workerExited) { + await sendRequest({ type: "killAll" }); + } + } catch (error) { + requestError = error; + beginWorkerFailure(error); + } + try { + if (workerFailureCleanup) { + cleanupError = await workerFailureCleanup; + } else { + await terminateRuntimePtys(); + } + } catch (error) { + cleanupError = error; + } finally { + for (const terminalId of Array.from(recordsById.keys())) { + removeRecord(terminalId); + } + if (worker.connected) worker.disconnect(); + if (!worker.killed && !workerShutdownTimer) { + workerShutdownTimer = setTimeout(() => { + worker.kill(); + }, 1000); + } + } + if (cleanupError) { + if (requestError) { + throw new AggregateError([requestError, cleanupError], "Terminal cleanup failed"); + } + throw cleanupError; + } + if (requestError) { + throw requestError; } }, @@ -858,6 +1089,6 @@ export function createWorkerTerminalManager( }; } -export function terminateWorkerTerminalManager(manager: TerminalManager): void { - manager.killAll(); +export function terminateWorkerTerminalManager(manager: TerminalManager): Promise { + return manager.killAll(); } diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 16cb20775b..d51ad346f9 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,6 +1,6 @@ import { resolve, dirname, basename } from "path"; -import { existsSync, realpathSync } from "fs"; -import { open as openFile, readFile, stat as statFile } from "fs/promises"; +import { realpathSync } from "fs"; +import { readFile } from "fs/promises"; import { TTLCache } from "@isaacs/ttlcache"; import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages"; import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; @@ -150,8 +150,8 @@ function rememberPullRequestStatus(cacheKey: string, status: PullRequestStatusRe } } -function getShortstatCacheKey(cwd: string): string { - return resolve(cwd); +function getShortstatCacheKey(cwd: string, context?: CheckoutContext): string { + return context?.cacheIdentity ?? resolve(cwd); } export function __resetPullRequestStatusCacheForTests(): void { @@ -847,6 +847,8 @@ export interface CheckoutContext { worktreesRoot?: string; logger?: Pick; facts?: CheckoutSnapshotFacts | null; + allowHostMetadata?: boolean; + cacheIdentity?: string; } export type CheckoutSnapshotFacts = @@ -899,7 +901,10 @@ async function requireGitWorktreeRoot(cwd: string): Promise { } } -export async function getCurrentBranch(cwd: string): Promise { +export async function getCurrentBranch( + cwd: string, + context?: CheckoutContext, +): Promise { try { const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], { cwd, @@ -907,7 +912,7 @@ export async function getCurrentBranch(cwd: string): Promise { }); const branch = stdout.trim(); if (branch === "HEAD") { - return await getRebaseHeadBranch(cwd); + return context?.allowHostMetadata === false ? null : await getRebaseHeadBranch(cwd); } return branch.length > 0 ? branch : null; } catch { @@ -1004,7 +1009,8 @@ async function getMainRepoRootFromCommonDir( if (!commonDir) { throw new Error("Not in a git repository"); } - const normalized = realpathSync(commonDir); + const normalized = + context?.allowHostMetadata === false ? resolve(commonDir) : realpathSync(commonDir); if (basename(normalized) === ".git") { return dirname(normalized); @@ -1142,6 +1148,9 @@ async function getPaseoWorktreeForCwd( cwd: string, options: PaseoWorktreeLookupOptions = {}, ): Promise { + if (options.context?.allowHostMetadata === false) { + return { isPaseoOwnedWorktree: false }; + } // Fast-path reject: non-worktree paths do not need expensive ownership checks. if (!/[\\/]worktrees[\\/]/.test(cwd)) { return { isPaseoOwnedWorktree: false }; @@ -1408,29 +1417,15 @@ async function resolveGitCommonDir(cwd: string): Promise { } async function abortGitPullConflictState(cwd: string): Promise { - const gitDir = await resolveAbsoluteGitDir(cwd); - if (!gitDir) { - return; - } - - const mergeHeadPath = resolve(gitDir, "MERGE_HEAD"); - const rebaseMergePath = resolve(gitDir, "rebase-merge"); - const rebaseApplyPath = resolve(gitDir, "rebase-apply"); - - if (existsSync(mergeHeadPath)) { - try { - await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 }); - } catch { - // ignore - } + try { + await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 }); + } catch { + // No merge was in progress. } - - if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) { - try { - await runGitCommand(["rebase", "--abort"], { cwd, timeout: 120_000 }); - } catch { - // ignore - } + try { + await runGitCommand(["rebase", "--abort"], { cwd, timeout: 120_000 }); + } catch { + // No rebase was in progress. } } @@ -1689,7 +1684,7 @@ async function inspectCheckoutContext( } const [currentBranch, remoteUrl, absoluteGitDir, gitCommonDir] = await Promise.all([ - getCurrentBranch(cwd), + getCurrentBranch(cwd, context), getOriginRemoteUrl(cwd), resolveAbsoluteGitDir(cwd), resolveGitCommonDir(cwd), @@ -2020,66 +2015,6 @@ function appendStructuredFile( structured.serializedBytes = nextBytes; return true; } -const UNTRACKED_BINARY_SNIFF_BYTES = 16 * 1024; - -async function isLikelyBinaryFile(absolutePath: string): Promise { - const handle = await openFile(absolutePath, "r"); - try { - const buffer = Buffer.allocUnsafe(UNTRACKED_BINARY_SNIFF_BYTES); - const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); - if (bytesRead === 0) { - return false; - } - - let suspicious = 0; - for (let i = 0; i < bytesRead; i += 1) { - const byte = buffer[i]; - if (byte === 0) { - return true; - } - // Treat control bytes as suspicious while allowing common whitespace. - if (byte < 7 || (byte > 14 && byte < 32) || byte === 127) { - suspicious += 1; - } - } - - return suspicious / bytesRead > 0.3; - } finally { - await handle.close(); - } -} - -async function inspectUntrackedFile( - cwd: string, - relativePath: string, -): Promise<{ stat: FileStat; truncated: boolean }> { - const absolutePath = resolve(cwd, relativePath); - const metadata = await statFile(absolutePath); - - if (!metadata.isFile()) { - return { stat: null, truncated: false }; - } - - if (await isLikelyBinaryFile(absolutePath)) { - return { - stat: { additions: 0, deletions: 0, isBinary: true }, - truncated: false, - }; - } - - if (metadata.size > PER_FILE_DIFF_MAX_BYTES) { - return { - stat: { additions: 0, deletions: 0, isBinary: false }, - truncated: true, - }; - } - - return { - stat: { additions: 0, deletions: 0, isBinary: false }, - truncated: false, - }; -} - function buildPlaceholderParsedDiffFile( change: CheckoutFileChange, options: { status: "too_large" | "binary"; stat?: FileStat }, @@ -2101,15 +2036,6 @@ async function getUntrackedDiffText( change: CheckoutFileChange, ignoreWhitespace = false, ): Promise<{ text: string; truncated: boolean; stat: FileStat }> { - try { - const inspected = await inspectUntrackedFile(cwd, change.path); - if (inspected.stat?.isBinary || inspected.truncated) { - return { text: "", truncated: inspected.truncated, stat: inspected.stat }; - } - } catch { - // Fall through to git diff path if metadata probing fails. - } - const result = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace, @@ -2125,7 +2051,11 @@ async function getUntrackedDiffText( return { text: result.stdout, truncated: result.truncated, - stat: { additions: 0, deletions: 0, isBinary: false }, + stat: { + additions: 0, + deletions: 0, + isBinary: /^Binary files .* differ$/m.test(result.stdout), + }, }; } @@ -2497,10 +2427,12 @@ export async function getCommitFileDiff({ cwd, sha, path, + allowFileRead = true, }: { cwd: string; sha: string; path: string; + allowFileRead?: boolean; }): Promise { const { stdout } = await runGitCommand( ["show", sha, "--format=", "--diff-merges=first-parent", "--", path], @@ -2517,6 +2449,7 @@ export async function getCommitFileDiff({ const parsedFiles = await parseAndHighlightDiff(stdout, cwd, { getOldFileContent: (file) => readGitFileContentAtRef(cwd, `${sha}^`, file.path), getNewFileContent: (file) => readGitFileContentAtRef(cwd, sha, file.path), + allowFileRead, }); // `--` scopes the diff to a single pathspec, so there is at most one real @@ -2579,18 +2512,20 @@ async function countUntrackedAdditions(cwd: string, throwOnGitError = false): Pr let additions = 0; for (const file of files.slice(0, UNTRACKED_SHORTSTAT_MAX_FILES)) { - const absolutePath = resolve(cwd, file); try { - const metadata = await statFile(absolutePath); - if (metadata.size > PER_FILE_DIFF_MAX_BYTES) continue; - if (await isLikelyBinaryFile(absolutePath)) continue; - const content = await readFile(absolutePath, "utf-8"); - if (content.length === 0) continue; - const normalized = content.replace(/\r\n/g, "\n"); - const lineCount = normalized.split("\n").length; - additions += normalized.endsWith("\n") ? lineCount - 1 : lineCount; + const { stdout: numstat } = await runGitCommand( + ["diff", "--no-index", "--numstat", "/dev/null", "--", file], + { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + maxOutputBytes: 8_192, + acceptExitCodes: [0, 1], + }, + ); + const added = numstat.split("\t", 1)[0]; + if (added && added !== "-") additions += Number.parseInt(added, 10) || 0; } catch { - // Skip unreadable files. + // Skip files Git cannot inspect. } } return additions; @@ -2702,7 +2637,7 @@ function getOrLoadCheckoutShortstat( context?: CheckoutContext, options?: CheckoutReadCacheOptions, ): Promise { - const cacheKey = getShortstatCacheKey(cwd); + const cacheKey = getShortstatCacheKey(cwd, context); if (!options?.force) { const cached = shortstatCache.get(cacheKey); if (cached !== undefined) { @@ -2872,7 +2807,7 @@ export function warmCheckoutShortstatInBackground( context?: CheckoutContext, onComplete?: () => void, ): void { - const cacheKey = getShortstatCacheKey(cwd); + const cacheKey = getShortstatCacheKey(cwd, context); if (shortstatCache.get(cacheKey) !== undefined || shortstatInFlight.has(cacheKey)) { return; } @@ -2896,6 +2831,7 @@ interface AppendStructuredTrackedDiffsInput { refsForDiff: CheckoutDiffRefs; ignoreWhitespace: boolean; structured: StructuredDiffAccumulator; + allowHostMetadata: boolean; appendTrackedPlaceholderComment: ( change: CheckoutFileChange, status: "binary" | "too_large", @@ -2907,8 +2843,9 @@ async function buildHighlightedTrackedDiffFile(input: { change: CheckoutFileChange; parsedFile: ParsedDiffFile; refsForDiff: CheckoutDiffRefs; + allowHostMetadata: boolean; }): Promise { - const { cwd, change, parsedFile, refsForDiff } = input; + const { cwd, change, parsedFile, refsForDiff, allowHostMetadata } = input; const refPath = change.oldPath ?? change.path; const [oldFileContent, newFileContent] = await Promise.all([ change.isNew ? null : readGitFileContentAtRef(cwd, refsForDiff.baseRef, refPath), @@ -2917,6 +2854,7 @@ async function buildHighlightedTrackedDiffFile(input: { const highlightedFile = await highlightDiffWithFileContent(parsedFile, cwd, { oldFileContent, newFileContent, + allowFileRead: allowHostMetadata, }); return { ...highlightedFile, @@ -2953,6 +2891,7 @@ async function appendStructuredTrackedDiffs( refsForDiff, ignoreWhitespace, structured, + allowHostMetadata, appendTrackedPlaceholderComment, } = input; @@ -2981,6 +2920,7 @@ async function appendStructuredTrackedDiffs( change, parsedFile, refsForDiff, + allowHostMetadata, }); if (!appendStructuredFile(structured, file)) { return false; @@ -3020,10 +2960,19 @@ interface ProcessUntrackedChangeInput { includeStructured: boolean; structured: StructuredDiffAccumulator; appendDiff: (text: string) => void; + allowHostMetadata: boolean; } async function processUntrackedChange(input: ProcessUntrackedChangeInput): Promise { - const { cwd, change, ignoreWhitespace, includeStructured, structured, appendDiff } = input; + const { + cwd, + change, + ignoreWhitespace, + includeStructured, + structured, + appendDiff, + allowHostMetadata, + } = input; const { text, truncated, stat } = await getUntrackedDiffText(cwd, change, ignoreWhitespace); if (!includeStructured) { @@ -3064,7 +3013,7 @@ async function processUntrackedChange(input: ProcessUntrackedChangeInput): Promi } appendDiff(text); - const parsed = await parseAndHighlightDiff(text, cwd); + const parsed = await parseAndHighlightDiff(text, cwd, { allowFileRead: allowHostMetadata }); const parsedFile = parsed[0] ?? ({ @@ -3271,6 +3220,7 @@ export async function getCheckoutDiff( refsForDiff: effectiveRefsForDiff, ignoreWhitespace, structured, + allowHostMetadata: context?.allowHostMetadata !== false, appendTrackedPlaceholderComment, }); if (!didAppendTrackedDiffs) { @@ -3296,6 +3246,7 @@ export async function getCheckoutDiff( includeStructured: compare.includeStructured === true, structured, appendDiff, + allowHostMetadata: context?.allowHostMetadata !== false, }); if (!didAppendUntrackedDiff) { return { diff: "", structured: [], diffTooLarge: true }; diff --git a/packages/server/src/utils/project-icon.ts b/packages/server/src/utils/project-icon.ts index ed122b4536..eb9719a987 100644 --- a/packages/server/src/utils/project-icon.ts +++ b/packages/server/src/utils/project-icon.ts @@ -502,13 +502,18 @@ export async function getProjectIcon(projectDir: string): Promise MAX_ICON_SIZE) { - return null; - } - const fileBuffer = await readFile(iconPath); - let mimeType = sniffMimeType(fileBuffer) ?? getMimeType(iconPath); + return projectIconFromBytes(iconPath, fileBuffer); + } catch { + return null; + } +} + +export function projectIconFromBytes(fileName: string, bytes: Uint8Array): ProjectIcon | null { + try { + if (bytes.byteLength > MAX_ICON_SIZE) return null; + const fileBuffer = Buffer.from(bytes); + let mimeType = sniffMimeType(fileBuffer) ?? getMimeType(fileName); let buffer: Buffer = fileBuffer; if (mimeType === "image/x-icon") { const pngFrame = extractIcoPngFrame(fileBuffer); @@ -523,8 +528,7 @@ export async function getProjectIcon(projectDir: string): Promise { + const directory = await mkdtemp(join(tmpdir(), "paseo-git-runtime-context-")); + directories.push(directory); + await execFileAsync("git", ["init", "--initial-branch", branch], { cwd: directory }); + return directory; +} + +function createRuntimeRunner( + runtimeCwd: string, + waitBeforeReturning?: Promise, +): (args: string[]) => Promise { + return async (args) => { + const { stdout, stderr } = await execFileAsync("git", args, { cwd: runtimeCwd }); + await waitBeforeReturning; + return { + stdout, + stderr, + truncated: false, + exitCode: 0, + signal: null, + }; + }; +} + +afterEach(async () => { + configureGitProcessPolicy(resolveGitProcessPolicy({ env: process.env })); + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); +}); + +describe("runGitCommand runtime submission context", () => { + it("keeps queued commands in the selected runtime that submitted them", async () => { + configureGitProcessPolicy({ maxProcessConcurrency: 1, maxProcessesPerSecond: 1_000 }); + const [runtimeA, runtimeB, hostDecoyA, hostDecoyB] = await Promise.all([ + createRepository("runtime-a"), + createRepository("runtime-b"), + createRepository("host-decoy-a"), + createRepository("host-decoy-b"), + ]); + let releaseRuntimeA!: () => void; + const runtimeAHold = new Promise((resolve) => { + releaseRuntimeA = resolve; + }); + + const first = runWithGitCommandRunner(createRuntimeRunner(runtimeA, runtimeAHold), () => + runGitCommand(["branch", "--show-current"], { cwd: hostDecoyA }), + ); + const second = runWithGitCommandRunner(createRuntimeRunner(runtimeB), () => + runGitCommand(["branch", "--show-current"], { cwd: hostDecoyB }), + ); + + releaseRuntimeA(); + + await expect(Promise.all([first, second])).resolves.toEqual([ + expect.objectContaining({ stdout: "runtime-a\n" }), + expect.objectContaining({ stdout: "runtime-b\n" }), + ]); + }); +}); diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts index 71c9e95f60..c25f39f442 100644 --- a/packages/server/src/utils/run-git-command.ts +++ b/packages/server/src/utils/run-git-command.ts @@ -27,6 +27,16 @@ const DEFAULT_STDERR_LIMIT = 2048; let gitProcessScheduler = new GitProcessScheduler(resolveGitProcessPolicy({ env: process.env })); let gitRuntimeMetrics = createGitCommandRuntimeMetricsWindow(gitProcessScheduler.policy); const gitCommandPriority = new AsyncLocalStorage(); +const gitCommandRunner = new AsyncLocalStorage(); + +export type GitCommandRunner = ( + args: string[], + options: GitCommandOptions, +) => Promise; + +export function runWithGitCommandRunner(runner: GitCommandRunner, operation: () => T): T { + return gitCommandRunner.run(runner, operation); +} export function runWithGitCommandPriority(priority: GitProcessPriority, operation: () => T): T { return gitCommandPriority.run(priority, operation); @@ -253,6 +263,8 @@ export function runGitCommand( args: string[], options: GitCommandOptions, ): Promise { + const selectedRunner = gitCommandRunner.getStore(); + const priority = gitCommandPriority.getStore() ?? "normal"; const metricsState = submitGitCommandMetric(args, options.cwd); const commandTrace = submitGitCommandTrace(args, options.cwd, { active: gitProcessScheduler.activeCount, @@ -260,6 +272,57 @@ export function runGitCommand( }); const runtimeMetric = gitRuntimeMetrics.submit(getGitOperation(args)); const startCommand = () => { + if (selectedRunner) { + const startedAt = Date.now(); + beginGitCommandMetric(metricsState); + gitRuntimeMetrics.start(runtimeMetric); + startGitCommandTrace(commandTrace, { + active: gitProcessScheduler.activeCount, + pending: gitProcessScheduler.pendingCount, + }); + const result = selectedRunner(args, options).then( + (commandResult) => { + const success = + commandResult.truncated || + (options.acceptExitCodes ?? [0]).includes(commandResult.exitCode ?? -1); + finishMetricOnce(commandResult, success); + return commandResult; + }, + (error) => { + finishMetricOnce( + { stdout: "", stderr: "", truncated: false, exitCode: null, signal: null }, + false, + ); + throw error; + }, + ); + return { + result, + exited: result.then( + () => undefined, + () => undefined, + ), + }; + + function finishMetricOnce(commandResult: GitCommandResult, success: boolean): void { + finishGitCommandMetric(metricsState, { + args, + cwd: options.cwd, + startedAtMs: startedAt, + durationMs: Date.now() - startedAt, + exitCode: commandResult.exitCode, + signal: commandResult.signal, + success, + }); + gitRuntimeMetrics.finish(runtimeMetric, { success, timedOut: false }); + settleGitCommandTrace(commandTrace, { + outcome: "closed", + exitCode: commandResult.exitCode, + signal: commandResult.signal, + truncated: commandResult.truncated, + }); + } + } let releaseProcessSlot!: () => void; const exited = new Promise((resolve) => { releaseProcessSlot = resolve; @@ -516,9 +579,7 @@ export function runGitCommand( }); return { result: resultPromise, exited }; }; - const promise = gitProcessScheduler.run(startCommand, { - priority: gitCommandPriority.getStore() ?? "normal", - }); + const promise = gitProcessScheduler.run(startCommand, { priority }); gitRuntimeMetrics.observeLimiter( gitProcessScheduler.activeCount, gitProcessScheduler.pendingCount, diff --git a/packages/server/src/utils/string-command-shell.ts b/packages/server/src/utils/string-command-shell.ts index 6d4fd80af6..e6b0b88167 100644 --- a/packages/server/src/utils/string-command-shell.ts +++ b/packages/server/src/utils/string-command-shell.ts @@ -9,8 +9,8 @@ export interface StringCommandShellInvocation { args: string[]; } -export function createStringCommandShellEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const sanitized = { ...env }; +export function createStringCommandShellEnv(env: T): T { + const sanitized = { ...env } as T; delete sanitized.BASH_ENV; return sanitized; } diff --git a/packages/server/src/utils/worktree-shell-selection.test.ts b/packages/server/src/utils/worktree-shell-selection.test.ts index 6c703e7188..ddb6717e96 100644 --- a/packages/server/src/utils/worktree-shell-selection.test.ts +++ b/packages/server/src/utils/worktree-shell-selection.test.ts @@ -1,5 +1,6 @@ import { type ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -31,13 +32,48 @@ function emitSuccessfulClose(child: ChildProcess): void { function createSpawnChildStub(): ChildProcess { const child = new EventEmitter() as ChildProcess; Object.assign(child, { - stdout: new EventEmitter(), - stderr: new EventEmitter(), + stdout: new PassThrough(), + stderr: new PassThrough(), + }); + queueMicrotask(() => { + (child.stdout as PassThrough).end(); + (child.stderr as PassThrough).end(); + emitSuccessfulClose(child); }); - queueMicrotask(() => emitSuccessfulClose(child)); return child; } +async function captureBoundSetupArgv( + resolveCommand: (command: string) => Promise, +): Promise { + const run = vi.fn(async () => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + stdout.end(); + stderr.end(); + return { + stdin: new PassThrough(), + stdout, + stderr, + exited: Promise.resolve({ code: 0, signal: null }), + kill: vi.fn(), + }; + }); + const { runWorktreeSetupCommands } = await import("./worktree.js"); + await runWorktreeSetupCommands({ + worktreePath: "/workspace", + branchName: "runtime-branch", + cleanupOnFailure: false, + execution: { + readFile: async () => + Buffer.from(JSON.stringify({ worktree: { setup: ["printf runtime-shell"] } })), + resolveCommand, + run, + }, + }); + return run.mock.calls[0]![0].argv; +} + describe("worktree shell selection", () => { const originalPlatform = process.platform; @@ -179,4 +215,62 @@ describe("worktree shell selection", () => { rmSync(worktreePath, { recursive: true, force: true }); } }); + + it("selects the bound runtime shell by capability instead of the host platform", async () => { + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const run = vi.fn(async () => { + stdout.end("runtime shell\n"); + stderr.end(); + return { + stdin: new PassThrough(), + stdout, + stderr, + exited: Promise.resolve({ code: 0, signal: null }), + kill: vi.fn(), + }; + }); + const { runWorktreeSetupCommands } = await import("./worktree.js"); + const results = await runWorktreeSetupCommands({ + worktreePath: "/workspace", + branchName: "runtime-branch", + cleanupOnFailure: false, + execution: { + readFile: async () => + Buffer.from(JSON.stringify({ worktree: { setup: ["printf runtime-shell"] } })), + resolveCommand: async (command) => (command === "bash" ? "/runtime/bin/bash" : null), + run, + }, + }); + + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + argv: ["/runtime/bin/bash", "-c", "printf runtime-shell"], + cwd: "/workspace", + }), + ); + expect(results).toMatchObject([{ stdout: "runtime shell\n", exitCode: 0 }]); + expect(spawnProcessMock).not.toHaveBeenCalled(); + }); + + it("prefers PowerShell when the runtime exposes Windows and POSIX shells", async () => { + const argv = await captureBoundSetupArgv(async (command) => `/runtime/bin/${command}`); + expect(argv).toEqual([ + "/runtime/bin/powershell", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + "printf runtime-shell", + ]); + }); + + it("falls back to cmd when a Windows runtime has no PowerShell", async () => { + const argv = await captureBoundSetupArgv(async (command) => + command === "cmd.exe" ? "C:\\Windows\\System32\\cmd.exe" : null, + ); + expect(argv).toEqual(["C:\\Windows\\System32\\cmd.exe", "/c", "printf runtime-shell"]); + }); }); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index e3cb98aba9..9159a2accc 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -4,11 +4,12 @@ import { constants as fsConstants, existsSync, mkdirSync, + readFileSync, realpathSync, rmSync, statSync, } from "fs"; -import { copyFile, rm, stat } from "fs/promises"; +import { copyFile, readFile, rm, stat } from "fs/promises"; import { join, basename, dirname, isAbsolute, resolve, sep } from "path"; import net from "node:net"; import { createHash } from "node:crypto"; @@ -17,7 +18,7 @@ import { buildStringCommandShellInvocation, createStringCommandShellEnv, } from "./string-command-shell.js"; -import { readPaseoConfigJson, resolvePaseoConfigPath } from "./paseo-config-file.js"; +import { resolvePaseoConfigPath } from "./paseo-config-file.js"; export { PaseoConfigRawSchema, PaseoLifecycleCommandRawSchema, @@ -44,7 +45,7 @@ import { createExternalProcessEnv } from "../server/paseo-env.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { validateBranchSlug } from "@getpaseo/protocol/branch-slug"; import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js"; -import { terminateWithTreeKill } from "./tree-kill.js"; +import { signalProcessTree } from "./tree-kill.js"; export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug"; @@ -105,6 +106,29 @@ export type WorktreeSetupCommandProgressEvent = stderr: string; }; +export interface WorktreeSetupExecution { + readFile(path: string): Promise; + resolveCommand(command: string): Promise; + run(input: { + argv: readonly [string, ...string[]]; + cwd: string; + env: Readonly>; + }): Promise<{ + stdin: NodeJS.WritableStream; + stdout: NodeJS.ReadableStream; + stderr: NodeJS.ReadableStream; + exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>; + kill(signal?: NodeJS.Signals): void; + }>; +} + +interface SetupCommandProcess { + stdout: NodeJS.ReadableStream; + stderr: NodeJS.ReadableStream; + exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>; + signal(signal: NodeJS.Signals): Promise; +} + export interface WorktreeTerminalConfig { name?: string; command: string; @@ -251,14 +275,29 @@ export type ReadPaseoConfigResult = | { ok: false; configPath: string; error: unknown }; export function readPaseoConfig(repoRoot: string): ReadPaseoConfigResult { + const configPath = resolvePaseoConfigPath(repoRoot); + if (!existsSync(configPath)) { + return { ok: true, config: null }; + } try { - const json = readPaseoConfigJson(repoRoot); - if (json === null) { - return { ok: true, config: null }; - } - return { ok: true, config: PaseoConfigSchema.parse(json) }; + return { ok: true, config: parsePaseoConfigContents(readFileSync(configPath)) }; } catch (error) { - return { ok: false, configPath: resolvePaseoConfigPath(repoRoot), error }; + return { ok: false, configPath, error }; + } +} + +export function parsePaseoConfigContents(contents: string | Uint8Array): PaseoConfig { + return PaseoConfigSchema.parse(JSON.parse(Buffer.from(contents).toString("utf8"))); +} + +export function parsePaseoConfigContentsOrThrow( + contents: string | Uint8Array, + configPath: string, +): PaseoConfig { + try { + return parsePaseoConfigContents(contents); + } catch (error) { + throw paseoConfigParseError({ configPath, error }); } } @@ -278,7 +317,7 @@ function readPaseoConfigOrThrow(repoRoot: string): PaseoConfig | null { } export function getWorktreeSetupCommands(repoRoot: string): string[] { - return readPaseoConfigOrThrow(repoRoot)?.worktree?.setup ?? []; + return [...worktreeSetupCommands(readPaseoConfigOrThrow(repoRoot))]; } export function getWorktreeTeardownCommands(repoRoot: string): string[] { @@ -412,7 +451,7 @@ export function processCarriageReturns(text: string): string { return output.join(""); } -async function execSetupCommand( +async function execLifecycleCommand( command: string, options: { cwd: string; env: NodeJS.ProcessEnv }, ): Promise { @@ -452,13 +491,15 @@ async function execSetupCommandStreamed(options: { total: number; signal?: AbortSignal; onEvent?: (event: WorktreeSetupCommandProgressEvent) => void; + launch(): Promise; }): Promise { return new Promise((resolvePromise) => { const startedAt = Date.now(); const stdoutChunks: string[] = []; const stderrChunks: string[] = []; let settled = false; - let termination: Promise | null = null; + let termination: Promise | null = null; + let child: SetupCommandProcess | null = null; const emitOutput = (stream: "stdout" | "stderr", chunk: string) => { const text = stripAnsi(chunk); @@ -518,45 +559,46 @@ async function execSetupCommandStreamed(options: { cwd: options.cwd, }); - const shellInvocation = buildStringCommandShellInvocation({ command: options.command }); - const child = spawnProcess(shellInvocation.shell, shellInvocation.args, { - cwd: options.cwd, - env: options.env, - shell: false, - stdio: ["ignore", "pipe", "pipe"], - }); - const abort = () => { - termination ??= terminateWithTreeKill(child, { - gracefulTimeoutMs: 1000, - forceTimeoutMs: 1000, - }); + if (!child) return; + termination ??= terminateSetupProcess(child); }; - if (options.signal?.aborted) { - abort(); - } else { - options.signal?.addEventListener("abort", abort, { once: true }); - } - - child.stdout?.on("data", (chunk: Buffer | string) => { - emitOutput("stdout", chunk.toString()); - }); - - child.stderr?.on("data", (chunk: Buffer | string) => { - emitOutput("stderr", chunk.toString()); - }); - - child.on("error", (error) => { - emitOutput("stderr", error instanceof Error ? error.message : String(error)); - void finish(null); - }); - - child.on("close", (code) => { - void finish(typeof code === "number" ? code : null); - }); + void options + .launch() + .then(async (launched) => { + child = launched; + options.signal?.addEventListener("abort", abort, { once: true }); + if (options.signal?.aborted) abort(); + const collect = async (stream: NodeJS.ReadableStream, name: "stdout" | "stderr") => { + for await (const chunk of stream) emitOutput(name, Buffer.from(chunk).toString("utf8")); + }; + const [, , exit] = await Promise.all([ + collect(launched.stdout, "stdout"), + collect(launched.stderr, "stderr"), + launched.exited, + ]); + return finish(exit.code); + }) + .catch(async (error) => { + emitOutput("stderr", error instanceof Error ? error.message : String(error)); + return finish(null); + }); }); } +async function terminateSetupProcess(child: SetupCommandProcess): Promise { + await child.signal("SIGTERM"); + const exited = await Promise.race([ + child.exited.then(() => true), + new Promise((resolvePromise) => { + const timer = setTimeout(() => resolvePromise(false), 1_000); + timer.unref(); + }), + ]); + if (!exited) await child.signal("SIGKILL"); + await child.exited; +} + async function getAvailablePort(): Promise { return new Promise((resolvePromise, reject) => { const server = net.createServer(); @@ -640,38 +682,47 @@ export async function runWorktreeSetupCommands(options: { runtimeEnv?: WorktreeRuntimeEnv; signal?: AbortSignal; onEvent?: (event: WorktreeSetupCommandProgressEvent) => void; + execution?: WorktreeSetupExecution; }): Promise { - // Read paseo.json from the worktree (it will have the same content as the source repo) - const setupCommands = getWorktreeSetupCommands(options.worktreePath); + const setupCommands = await readSetupCommands(options.worktreePath, options.execution); if (setupCommands.length === 0) { return []; } const runtimeEnv = options.runtimeEnv ?? - (await resolveWorktreeRuntimeEnv({ - worktreePath: options.worktreePath, - branchName: options.branchName, - ...(options.repoRootPath ? { repoRootPath: options.repoRootPath } : {}), - })); - const setupEnv = createStringCommandShellEnv(createExternalProcessEnv(process.env, runtimeEnv)); + (options.execution + ? {} + : await resolveWorktreeRuntimeEnv({ + worktreePath: options.worktreePath, + branchName: options.branchName, + ...(options.repoRootPath ? { repoRootPath: options.repoRootPath } : {}), + })); + const setupEnv = options.execution + ? {} + : createStringCommandShellEnv(createExternalProcessEnv(process.env, runtimeEnv)); const results: WorktreeSetupCommandResult[] = []; for (const [index, cmd] of setupCommands.entries()) { - const result = options.onEvent - ? await execSetupCommandStreamed({ + const commandInput = { + command: cmd, + cwd: options.worktreePath, + env: setupEnv, + index: index + 1, + total: setupCommands.length, + signal: options.signal, + onEvent: options.onEvent, + }; + const result = await execSetupCommandStreamed({ + ...commandInput, + launch: () => + launchSetupCommand({ command: cmd, cwd: options.worktreePath, env: setupEnv, - index: index + 1, - total: setupCommands.length, - signal: options.signal, - onEvent: options.onEvent, - }) - : await execSetupCommand(cmd, { - cwd: options.worktreePath, - env: setupEnv, - }); + execution: options.execution, + }), + }); results.push(result); if (result.exitCode !== 0) { @@ -695,6 +746,97 @@ export async function runWorktreeSetupCommands(options: { return results; } +async function launchSetupCommand(options: { + command: string; + cwd: string; + env: NodeJS.ProcessEnv; + execution?: WorktreeSetupExecution; +}): Promise { + const env = Object.fromEntries( + Object.entries(options.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ); + if (options.execution) { + const shell = await resolveRuntimeSetupShell(options.execution, options.command); + const child = await options.execution.run({ argv: shell, cwd: options.cwd, env }); + child.stdin.end(); + return { + stdout: child.stdout, + stderr: child.stderr, + exited: child.exited, + async signal(signal) { + child.kill(signal); + }, + }; + } + + const shell = buildStringCommandShellInvocation({ command: options.command }); + const child = spawnProcess(shell.shell, shell.args, { + cwd: options.cwd, + env, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolveExit) => { + child.once("error", () => resolveExit({ code: null, signal: null })); + child.once("close", (code, signal) => resolveExit({ code, signal })); + }, + ); + return { + stdout: child.stdout!, + stderr: child.stderr!, + exited, + signal: (signal) => signalProcessTree(child, signal), + }; +} + +async function resolveRuntimeSetupShell( + execution: WorktreeSetupExecution, + command: string, +): Promise { + const cmd = await execution.resolveCommand("cmd.exe"); + if (cmd) { + const powershell = + (await execution.resolveCommand("powershell")) ?? + (await execution.resolveCommand("powershell.exe")); + const invocation = buildStringCommandShellInvocation({ + command, + platform: "win32", + ...(powershell ? {} : { windowsShell: "cmd" as const }), + }); + return [powershell ?? cmd, ...invocation.args]; + } + + const bash = await execution.resolveCommand("bash"); + if (bash) { + const invocation = buildStringCommandShellInvocation({ command, platform: "linux" }); + return [bash, ...invocation.args]; + } + throw new Error("Setup runtime has no supported command shell"); +} + +async function readSetupCommands( + worktreePath: string, + execution?: WorktreeSetupExecution, +): Promise { + const contents = execution + ? await execution.readFile("paseo.json") + : await readFile(resolvePaseoConfigPath(worktreePath)).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (!contents) return []; + return worktreeSetupCommands( + parsePaseoConfigContentsOrThrow(contents, resolvePaseoConfigPath(worktreePath)), + ); +} + +function worktreeSetupCommands(config: PaseoConfig | null): readonly string[] { + return config?.worktree?.setup ?? []; +} + async function resolveBranchNameForWorktreePath(worktreePath: string): Promise { try { const { stdout } = await runGitCommand(["branch", "--show-current"], { @@ -783,7 +925,7 @@ export async function runWorktreeTeardownCommands(options: { const results: WorktreeTeardownCommandResult[] = []; for (const cmd of teardownCommands) { - const result = await execSetupCommand(cmd, { + const result = await execLifecycleCommand(cmd, { cwd: teardownCwd, env: teardownEnv, }); diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index c8dc40a75c..783bbe7fd4 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -1,158 +1,245 @@ { - "$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": { - "listen": { - "type": "string" + "enabled": { + "type": "boolean" }, - "hostnames": { - "anyOf": [ - { - "type": "boolean", - "const": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] + "injectIntoAgents": { + "type": "boolean" + } + }, + "additionalProperties": {} + }, + "browserTools": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "additionalProperties": {} + }, + "git": { + "type": "object", + "properties": { + "maxProcessesPerSecond": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 }, - "allowedHosts": { - "anyOf": [ - { - "type": "boolean", - "const": true - }, - { - "type": "array", - "items": { - "type": "string" - } + "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" + } }, - "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" - } - } - ] + "required": ["id", "name", "command"], + "additionalProperties": {} + } + }, + "cors": { + "type": "object", + "properties": { + "allowedOrigins": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "relay": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" }, - "mcp": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "injectIntoAgents": { - "type": "boolean" - } - }, - "additionalProperties": true + "endpoint": { + "type": "string" }, - "git": { - "type": "object", - "properties": { - "maxProcessesPerSecond": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "maxProcessConcurrency": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "additionalProperties": false + "publicEndpoint": { + "type": "string" }, - "autoArchiveAfterMerge": { + "useTls": { "type": "boolean" }, - "enableTerminalAgentHooks": { + "publicUseTls": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "serviceProxy": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, - "appendSystemPrompt": { + "listen": { "type": "string" }, - "cors": { - "type": "object", - "properties": { - "allowedOrigins": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false + "publicBaseUrl": { + "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 + } + }, + "additionalProperties": false + }, + "app": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string" + } + }, + "additionalProperties": false + }, + "providers": { + "type": "object", + "properties": { + "openai": { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "minLength": 1 }, - "relay": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "endpoint": { - "type": "string" - }, - "publicEndpoint": { - "type": "string" - }, - "useTls": { - "type": "boolean" - }, - "publicUseTls": { - "type": "boolean" - } - }, - "additionalProperties": false + "baseUrl": { + "type": "string", + "minLength": 1 }, - "serviceProxy": { + "stt": { "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" + "apiKey": { + "type": "string", + "minLength": 1 }, - "publicBaseUrl": { - "description": "Optional public base URL for service host aliases.", + "baseUrl": { "type": "string", - "format": "uri" + "minLength": 1 } }, "additionalProperties": false }, - "auth": { + "tts": { "type": "object", "properties": { - "password": { + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { "type": "string", - "pattern": "^\\$2[aby]\\$\\d{2}\\$[./A-Za-z0-9]{53}$" + "minLength": 1 } }, "additionalProperties": false @@ -160,179 +247,258 @@ }, "additionalProperties": false }, - "app": { + "local": { "type": "object", "properties": { - "baseUrl": { - "type": "string" + "modelsDir": { + "type": "string", + "minLength": 1 } }, "additionalProperties": false + } + }, + "additionalProperties": false + }, + "worktrees": { + "type": "object", + "properties": { + "root": { + "type": "string", + "minLength": 1 }, - "worktrees": { + "servicePorts": { "type": "object", "properties": { - "root": { + "range": { + "type": "string", + "pattern": "^(\\d{1,5})-(\\d{1,5})$" + }, + "portScript": { "type": "string", "minLength": 1 } }, "additionalProperties": false - }, - "providers": { - "type": "object", - "properties": { - "openai": { - "type": "object", - "properties": { - "apiKey": { + } + }, + "additionalProperties": false + }, + "workspaceRuntimes": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "anyOf": [ + { + "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", + "propertyNames": { + "type": "string" }, - "baseUrl": { + "additionalProperties": { + "$ref": "#/definitions/__schema0" + } + } + }, + "required": ["type", "command"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "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" }, - "stt": { + "additionalProperties": {} + }, + "models": { + "type": "array", + "items": { "type": "object", "properties": { - "apiKey": { + "id": { "type": "string", "minLength": 1 }, - "baseUrl": { + "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 - }, - "tts": { + "required": ["id", "label"] + } + }, + "additionalModels": { + "type": "array", + "items": { "type": "object", "properties": { - "apiKey": { + "id": { "type": "string", "minLength": 1 }, - "baseUrl": { + "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 + "required": ["id", "label"] } }, - "additionalProperties": false - }, - "local": { - "type": "object", - "properties": { - "modelsDir": { - "type": "string", - "minLength": 1 + "disallowedTools": { + "type": "array", + "items": { + "type": "string" } }, - "additionalProperties": false + "enabled": { + "type": "boolean" + }, + "order": { + "type": "number" + } } - }, - "additionalProperties": false + } }, - "agents": { + "metadataGeneration": { "type": "object", "properties": { "providers": { - "type": "object", - "additionalProperties": { + "type": "array", + "items": { "type": "object", "properties": { - "extends": { - "type": "string" - }, - "label": { - "type": "string" + "provider": { + "type": "string", + "minLength": 1 }, - "description": { - "type": "string" + "model": { + "type": "string", + "minLength": 1 }, - "command": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - }, - "minItems": 1 - }, - "env": { - "type": "object", - "additionalProperties": { - "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 - } - } - }, - "required": ["id", "label"], - "additionalProperties": false - } - }, - "additionalModels": { - "type": "array", - "items": { - "$ref": "#/definitions/PaseoConfigV1/properties/agents/properties/providers/additionalProperties/properties/models/items" - } - }, - "disallowedTools": { - "type": "array", - "items": { - "type": "string" - } - }, - "enabled": { - "type": "boolean" - }, - "order": { - "type": "number" + "thinkingOptionId": { + "type": "string", + "minLength": 1 } }, + "required": ["provider"], "additionalProperties": false } }, @@ -343,127 +509,108 @@ } }, "additionalProperties": false - }, - "features": { + } + }, + "additionalProperties": false + }, + "features": { + "type": "object", + "properties": { + "dictation": { "type": "object", "properties": { - "dictation": { + "enabled": { + "type": "boolean" + }, + "stt": { "type": "object", "properties": { - "enabled": { - "type": "boolean" + "provider": { + "type": "string" }, - "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 + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + }, + "confidenceThreshold": { + "type": "number" } }, "additionalProperties": false + } + }, + "additionalProperties": false + }, + "voiceMode": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" }, - "voiceMode": { + "llm": { "type": "object", "properties": { - "enabled": { - "type": "boolean" - }, - "llm": { - "type": "object", - "properties": { - "provider": { - "type": "string" - }, - "model": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false + "provider": { + "type": "string" }, - "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 + "model": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "stt": { + "type": "object", + "properties": { + "provider": { + "type": "string" }, - "turnDetection": { - "type": "object", - "properties": { - "provider": { - "$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider" - } - }, - "additionalProperties": false + "model": { + "type": "string", + "minLength": 1 }, - "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 + "language": { + "type": "string", + "minLength": 1 } }, "additionalProperties": false }, - "webUi": { + "turnDetection": { "type": "object", "properties": { - "enabled": { - "type": "boolean" + "provider": { + "type": "string" + } + }, + "additionalProperties": false + }, + "tts": { + "type": "object", + "properties": { + "provider": { + "type": "string" }, - "distDir": { + "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 @@ -471,7 +618,34 @@ }, "additionalProperties": false }, - "log": { + "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": { @@ -481,42 +655,32 @@ "format": { "type": "string", "enum": ["pretty", "json"] + } + }, + "additionalProperties": false + }, + "file": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": ["trace", "debug", "info", "warn", "error", "fatal"] }, - "console": { - "type": "object", - "properties": { - "level": { - "$ref": "#/definitions/PaseoConfigV1/properties/log/properties/level" - }, - "format": { - "$ref": "#/definitions/PaseoConfigV1/properties/log/properties/format" - } - }, - "additionalProperties": false + "path": { + "type": "string", + "minLength": 1 }, - "file": { + "rotate": { "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 @@ -528,5 +692,39 @@ "additionalProperties": false } }, - "$schema": "http://json-schema.org/draft-07/schema#" + "additionalProperties": false, + "definitions": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/definitions/__schema0" + } + } + ] + } + }, + "title": "PaseoConfigV1" } diff --git a/packages/workspace-helper/README.md b/packages/workspace-helper/README.md new file mode 100644 index 0000000000..48e9b33d57 --- /dev/null +++ b/packages/workspace-helper/README.md @@ -0,0 +1,12 @@ +# `@getpaseo/workspace-helper` + +This is the official workspace filesystem helper for Paseo command runtimes. It owns the +executable, protocol, typed client binding, and confinement rules. + +Runtime authors depend on the same release as `@getpaseo/workspace-runtime-contract` and bundle +the `paseo-workspace-helper` bin. Do not implement the helper protocol. The command-runtime +contract and helper protocol have independent version fields; a Paseo release pins compatible +package versions and verifies both describe responses before using a runtime. + +The helper receives workspace authority only from its process cwd. All API paths are relative. +Absolute paths, traversal, and symlink escapes are rejected. diff --git a/packages/workspace-helper/package.json b/packages/workspace-helper/package.json new file mode 100644 index 0000000000..893236887a --- /dev/null +++ b/packages/workspace-helper/package.json @@ -0,0 +1,39 @@ +{ + "name": "@getpaseo/workspace-helper", + "version": "0.4.0", + "description": "Official confined workspace filesystem helper and typed binding for Paseo runtimes", + "bin": { + "paseo-workspace-helper": "./dist/executable.mjs" + }, + "files": [ + "dist", + "README.md" + ], + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "clean": "node ../../scripts/clean-package-dist.mjs", + "build": "tsc -p tsconfig.json --incremental false && node -e \"const fs=require('node:fs');fs.copyFileSync('src/executable.mjs','dist/executable.mjs');fs.chmodSync('dist/executable.mjs',0o755)\"", + "build:clean": "npm run clean && npm run build", + "prepack": "npm run build:clean", + "test": "npm run build && vitest run test/command-resolution.test.mjs test/workspace-helper.posix.test.ts --bail=1", + "test:standalone": "node --test test/standalone.test.mjs", + "typecheck": "tsgo -p tsconfig.json --noEmit" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^20.9.0", + "typescript": "^5.9.3", + "vitest": "^4.1.6" + } +} diff --git a/packages/workspace-helper/src/binding.ts b/packages/workspace-helper/src/binding.ts new file mode 100644 index 0000000000..ecf608771e --- /dev/null +++ b/packages/workspace-helper/src/binding.ts @@ -0,0 +1,26 @@ +import type { Readable, Writable } from "node:stream"; + +import type { WorkspaceFiles } from "./files.js"; +import { createClient } from "./client.js"; + +export interface WorkspaceHelperProcess { + stdin: Writable; + stdout: Readable; + stderr: Readable; + exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>; + kill(signal?: NodeJS.Signals): void; +} + +export interface WorkspaceFilesOwner { + files: WorkspaceFiles; + resolveCommand(command: string): Promise; + verify(): Promise; + close(reason?: Error): Promise; +} + +export function bindWorkspaceHelper(options: { + command: readonly [string, ...string[]]; + launch(argv: readonly [string, ...string[]]): Promise; +}): WorkspaceFilesOwner { + return createClient(options); +} diff --git a/packages/workspace-helper/src/client.ts b/packages/workspace-helper/src/client.ts new file mode 100644 index 0000000000..9a701ef1f1 --- /dev/null +++ b/packages/workspace-helper/src/client.ts @@ -0,0 +1,584 @@ +import type { WorkspaceFileContent, WorkspaceFileRead, WorkspaceWatchEvent } from "./files.js"; +import type { WorkspaceFilesOwner, WorkspaceHelperProcess } from "./binding.js"; +import { + describeSchema, + directorySchema, + fileStatSchema, + resolvedCommandSchema, + watchEventSchema, + writeResultSchema, +} from "./protocol.js"; +import type { z } from "zod"; + +interface WatchState { + process: WorkspaceHelperProcess; + subscriptions: Map void>; + acknowledgements: Map; + closed: boolean; + cleanup: Promise | null; + diagnostics: Promise; + ready: boolean; +} + +interface LogicalWatchSubscription { + input: { paths: readonly string[]; recursive?: boolean; ignoredPaths?: readonly string[] }; + listener: (event: WorkspaceWatchEvent) => void; +} + +const WATCH_ACKNOWLEDGEMENT_TIMEOUT_MS = 3_000; +const WATCH_CLOSE_TIMEOUT_MS = 500; +const PROCESS_STOP_TIMEOUT_MS = 1_000; + +export function createClient(options: { + launch(argv: readonly [string, ...string[]]): Promise; + command: readonly [string, ...string[]]; +}): WorkspaceFilesOwner { + let watcher: WatchState | null = null; + let nextSubscriptionId = 0; + let closing = false; + let closePromise: Promise | null = null; + let watcherRecovery: Promise | null = null; + let watcherStart: Promise | null = null; + let replayAfterFailure = false; + const watchSubscriptions = new Map(); + const processes = new Set(); + + const command = async (name: string, args: readonly string[] = []) => { + if (closing) throw new Error("Workspace files client is closed"); + const child = await options.launch([...options.command, name, ...args]); + processes.add(child); + void child.exited.then( + () => processes.delete(child), + () => processes.delete(child), + ); + if (closing) { + await terminate(child); + throw new Error("Workspace files client is closed"); + } + return child; + }; + + async function json( + name: string, + args: readonly string[], + schema: T, + ): Promise> { + const child = await command(name, args); + child.stdin.end(); + const [stdout, stderr, exit] = await Promise.all([ + collect(child.stdout), + collect(child.stderr), + child.exited, + ]); + if (exit.code !== 0 || exit.signal !== null) throw helperError(stderr, exit); + return schema.parse(JSON.parse(stdout)); + } + + return { + files: { + stat(path) { + return json("fs-stat", ["--path", requireRelativePath(path)], fileStatSchema); + }, + list(path) { + return json("fs-list", ["--path", requireRelativePath(path)], directorySchema); + }, + async read(path) { + path = requireRelativePath(path); + const metadata = await json("fs-stat", ["--path", path], fileStatSchema); + if (metadata.status !== "ready") { + throw new Error(metadata.status === "error" ? metadata.error : `File not found: ${path}`); + } + const child = await command("fs-read", ["--path", path]); + child.stdin.end(); + const stderr = collect(child.stderr); + const chunks = ownedRead(child, stderr, async () => { + const final = await json("fs-stat", ["--path", path], fileStatSchema); + if (final.status !== "ready" || final.revision !== metadata.revision) { + throw new Error("File changed during transfer"); + } + }); + return { ...metadata, chunks } satisfies WorkspaceFileRead; + }, + async write(input) { + const writtenPath = requireRelativePath(input.path); + const args = ["--path", writtenPath]; + if (input.expectedModifiedAt) args.push("--expected-modified-at", input.expectedModifiedAt); + if (input.expectedRevision) args.push("--expected-revision", input.expectedRevision); + const child = await command("fs-write", args); + const stdout = collect(child.stdout); + const stderr = collect(child.stderr); + try { + if (input.contents instanceof Uint8Array) { + child.stdin.end(input.contents); + } else { + for await (const chunk of input.contents) { + if (!child.stdin.write(chunk)) await onceDrain(child.stdin); + } + child.stdin.end(); + } + } catch (error) { + await terminate(child); + throw error; + } + const [body, diagnostics, exit] = await Promise.all([stdout, stderr, child.exited]); + if (exit.code !== 0 || exit.signal !== null) throw helperError(diagnostics, exit); + const result = writeResultSchema.parse(JSON.parse(body)); + if (result.status === "written") notifyWrite(writtenPath); + return result; + }, + async subscribe(input, listener) { + if (closing) throw new Error("Workspace files client is closed"); + const confinedInput = { + ...input, + paths: input.paths.map(requireRelativePath), + ...(input.ignoredPaths + ? { ignoredPaths: input.ignoredPaths.map(requireRelativePath) } + : {}), + }; + const id = `subscription-${++nextSubscriptionId}`; + watchSubscriptions.set(id, { input: confinedInput, listener }); + let state: WatchState | null = null; + try { + state = await requireWatcher(); + if (state.subscriptions.has(id)) return subscriptionFor(id); + state.subscriptions.set(id, listener); + await sendSubscription(state, id, confinedInput); + } catch (error) { + watchSubscriptions.delete(id); + state?.subscriptions.delete(id); + throw error; + } + return subscriptionFor(id); + }, + }, + async resolveCommand(commandName) { + const result = await json("resolve-command", ["--name", commandName], resolvedCommandSchema); + return result.path; + }, + async verify() { + const description = await json("describe", [], describeSchema); + if ( + !description.capabilities.includes("files") || + !description.capabilities.includes("watch") || + !description.capabilities.includes("resolve-command") + ) { + throw new Error( + "Workspace helper does not support files, watching, and command resolution", + ); + } + }, + async close(reason) { + closePromise ??= (async () => { + closing = true; + watchSubscriptions.clear(); + if (watcher) { + await stopWatcher(watcher, reason); + } + await watcherRecovery?.catch(() => undefined); + await Promise.all([...processes].map((child) => terminate(child))); + })(); + await closePromise; + }, + }; + + function subscriptionFor(id: string) { + let active = true; + return { + async unsubscribe() { + if (!active) return; + active = false; + watchSubscriptions.delete(id); + await watcherRecovery?.catch(() => undefined); + const state = watcher; + state?.subscriptions.delete(id); + if (state && !state.closed) { + const removed = acknowledgement(state, `unsubscribed:${id}`); + state.process.stdin.write( + `${JSON.stringify({ protocolVersion: 1, type: "unsubscribe", id })}\n`, + ); + try { + await waitForAcknowledgement( + state, + `unsubscribed:${id}`, + "Workspace helper unsubscribe acknowledgement timed out", + removed, + ); + } catch (error) { + if (!(error instanceof Error) || error.message !== "Workspace helper watcher is closed") + throw error; + } + } + if (watchSubscriptions.size === 0 && state) await stopWatcher(state); + }, + }; + } + + function notifyWrite(writtenPath: string): void { + for (const subscription of watchSubscriptions.values()) { + if (!subscriptionIncludesPath(subscription.input, writtenPath)) continue; + subscription.listener({ type: "changed", paths: [writtenPath] }); + } + } + + async function requireWatcher(): Promise { + await watcherRecovery; + if (watcherStart) return watcherStart; + if (watcher && !watcher.closed) return watcher; + return launchWatcher(); + } + + function launchWatcher(): Promise { + watcherStart ??= startWatcher().finally(() => { + watcherStart = null; + }); + return watcherStart; + } + + async function startWatcher(): Promise { + const replaying = replayAfterFailure; + const process = await command("watch"); + const state: WatchState = { + process, + subscriptions: new Map(), + acknowledgements: new Map(), + closed: false, + cleanup: null, + diagnostics: collect(process.stderr), + ready: false, + }; + watcher = state; + process.stdin.on("error", () => undefined); + const ready = acknowledgement(state, "ready"); + void consumeLines(process.stdout, (line) => handleWatchEvent(state, line)) + .then( + () => failWatcher(state, new Error("Workspace helper watcher output ended")), + (error) => failWatcher(state, toError(error)), + ) + .catch(() => undefined); + void process.exited + .then( + (exit) => { + return failWatcher( + state, + new Error(`Workspace helper watcher exited: ${exit.code ?? exit.signal}`), + ); + }, + (error) => { + return failWatcher(state, toError(error)); + }, + ) + .catch(() => undefined); + await waitForAcknowledgement( + state, + "ready", + "Workspace helper ready acknowledgement timed out", + ready, + ); + for (const [id, subscription] of watchSubscriptions) { + state.subscriptions.set(id, subscription.listener); + await sendSubscription(state, id, subscription.input); + } + state.ready = true; + replayAfterFailure = false; + if (replaying) { + for (const subscription of watchSubscriptions.values()) { + subscription.listener({ type: "changed", paths: [...subscription.input.paths] }); + } + } + return state; + } + + async function sendSubscription( + state: WatchState, + id: string, + input: LogicalWatchSubscription["input"], + ): Promise { + const acknowledged = acknowledgement(state, `subscribed:${id}`); + state.process.stdin.write( + `${JSON.stringify({ protocolVersion: 1, type: "subscribe", id, paths: input.paths, recursive: input.recursive === true, ignoredPaths: input.ignoredPaths ?? [] })}\n`, + ); + await waitForAcknowledgement( + state, + `subscribed:${id}`, + "Workspace helper subscribe acknowledgement timed out", + acknowledged, + ); + } + + async function stopWatcher(state: WatchState, reason?: Error): Promise { + if (state.cleanup) return state.cleanup; + if (state.closed) return; + state.closed = true; + if (watcher === state) watcher = null; + if (reason) { + for (const listener of state.subscriptions.values()) { + listener({ type: "error", error: reason.message }); + } + } + state.subscriptions.clear(); + const closedError = reason ?? new Error("Workspace helper watcher is closed"); + for (const pending of state.acknowledgements.values()) pending.reject(closedError); + state.acknowledgements.clear(); + state.process.stdin.end(`${JSON.stringify({ protocolVersion: 1, type: "close" })}\n`); + state.cleanup = (async () => { + const gracefulExit = await exitWithin(state.process.exited, WATCH_CLOSE_TIMEOUT_MS); + if (!gracefulExit) await terminate(state.process); + state.process.stdin.destroy(); + state.process.stdout.destroy(); + await state.diagnostics.catch(() => undefined); + return undefined; + })(); + await state.cleanup; + } + + async function waitForAcknowledgement( + state: WatchState, + key: string, + timeoutMessage: string, + pending: Promise, + ): Promise { + try { + await withTimeout(pending, WATCH_ACKNOWLEDGEMENT_TIMEOUT_MS, timeoutMessage); + } catch (error) { + state.acknowledgements.delete(key); + const failure = toError(error); + await failWatcher(state, failure); + throw failure; + } + } + + async function failWatcher(state: WatchState, error: Error): Promise { + if (state.cleanup) return state.cleanup; + if (state.closed) return; + state.closed = true; + if (watcher === state) watcher = null; + state.subscriptions.clear(); + for (const pending of state.acknowledgements.values()) pending.reject(error); + state.acknowledgements.clear(); + state.cleanup = terminate(state.process).then(async () => { + state.process.stdin.destroy(); + state.process.stdout.destroy(); + await state.diagnostics.catch(() => undefined); + return undefined; + }); + if (!closing && state.ready && watchSubscriptions.size > 0 && !watcherRecovery) { + replayAfterFailure = true; + watcherRecovery = state.cleanup + .then(async () => { + if (!closing && watchSubscriptions.size > 0) await launchWatcher(); + return undefined; + }) + .catch((recoveryError) => { + for (const subscription of watchSubscriptions.values()) { + subscription.listener({ type: "error", error: toError(recoveryError).message }); + } + }) + .finally(() => { + watcherRecovery = null; + }); + } + await state.cleanup; + } +} + +function handleWatchEvent(state: WatchState, line: string): void { + const event = watchEventSchema.parse(JSON.parse(line)); + const subscriptionId = "subscriptionId" in event ? event.subscriptionId : undefined; + let acknowledgementKey: string | null = null; + if (event.type === "ready") acknowledgementKey = "ready"; + else if (subscriptionId) acknowledgementKey = `${event.type}:${subscriptionId}`; + if (acknowledgementKey) { + state.acknowledgements.get(acknowledgementKey)?.resolve(); + state.acknowledgements.delete(acknowledgementKey); + } + if (!subscriptionId) return; + const listener = state.subscriptions.get(subscriptionId); + if (!listener) return; + if (event.type === "changed") listener({ type: "changed", paths: event.paths ?? [] }); + if (event.type === "overflow") listener({ type: "overflow" }); + if (event.type === "error") listener({ type: "error", error: event.message ?? "Watcher failed" }); +} + +function acknowledgement(state: WatchState, key: string): Promise { + const pending = new Promise((resolve, reject) => + state.acknowledgements.set(key, { resolve, reject }), + ); + void pending.catch(() => undefined); + return pending; +} + +async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +async function collect(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +function ownedRead( + child: WorkspaceHelperProcess, + stderr: Promise, + verify: () => Promise, +): WorkspaceFileContent { + const source = child.stdout[Symbol.asyncIterator](); + let iteratorCreated = false; + let finished = false; + let cancellation: Promise | null = null; + + async function cancel(): Promise { + if (finished) return; + cancellation ??= terminate(child).then(async () => { + finished = true; + child.stdout.destroy(); + child.stdin.destroy(); + await stderr.catch(() => undefined); + return undefined; + }); + await cancellation; + } + + return { + cancel, + [Symbol.asyncIterator]() { + if (iteratorCreated) throw new Error("Workspace file content can only be consumed once"); + iteratorCreated = true; + return { + async next() { + if (finished) return { done: true, value: undefined }; + try { + const result = await source.next(); + if (!result.done) { + return { + done: false, + value: Buffer.isBuffer(result.value) ? result.value : Buffer.from(result.value), + }; + } + const [diagnostics, exit] = await Promise.all([stderr, child.exited]); + finished = true; + if (exit.code !== 0 || exit.signal !== null) throw helperError(diagnostics, exit); + await verify(); + return { done: true, value: undefined }; + } catch (error) { + await cancel(); + throw error; + } + }, + async return() { + await cancel(); + return { done: true, value: undefined }; + }, + async throw(error) { + await cancel(); + throw error; + }, + }; + }, + }; +} + +async function terminate(child: WorkspaceHelperProcess): Promise { + child.kill("SIGTERM"); + let exit = await exitWithin(child.exited, PROCESS_STOP_TIMEOUT_MS); + if (!exit) { + child.kill("SIGKILL"); + exit = await exitWithin(child.exited, PROCESS_STOP_TIMEOUT_MS); + } + if (!exit) throw new Error("Workspace helper process did not exit after SIGKILL"); +} + +async function exitWithin( + exited: WorkspaceHelperProcess["exited"], + timeoutMs: number, +): Promise | null> { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + exited, + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function helperError( + stderr: string, + exit: { code: number | null; signal: NodeJS.Signals | null }, +): Error { + return new Error(stderr.trim() || `Workspace helper failed: ${exit.code ?? exit.signal}`); +} + +function requireRelativePath(value: string): string { + const segments = value.split(/[\\/]/u); + if ( + !value || + value.startsWith("/") || + /^[A-Za-z]:\//u.test(value) || + value.includes("\\") || + segments.includes("..") + ) { + throw new Error(`Workspace helper path must be relative: ${value}`); + } + return value; +} + +function subscriptionIncludesPath( + input: LogicalWatchSubscription["input"], + changedPath: string, +): boolean { + if (input.ignoredPaths?.some((ignoredPath) => isPathInside(changedPath, ignoredPath))) { + return false; + } + return input.paths.some( + (watchedPath) => + changedPath === watchedPath || + (input.recursive === true && isPathInside(changedPath, watchedPath)), + ); +} + +function isPathInside(candidate: string, directory: string): boolean { + return directory === "." || candidate === directory || candidate.startsWith(`${directory}/`); +} + +function onceDrain(stream: NodeJS.WritableStream): Promise { + return new Promise((resolve, reject) => { + stream.once("drain", resolve); + stream.once("error", reject); + }); +} + +async function consumeLines( + stream: NodeJS.ReadableStream, + consume: (line: string) => void, +): Promise { + let buffered = ""; + for await (const chunk of stream) { + buffered += chunk.toString(); + for (;;) { + const newline = buffered.indexOf("\n"); + if (newline < 0) break; + const line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + if (line) consume(line); + } + } +} diff --git a/packages/workspace-helper/src/executable.mjs b/packages/workspace-helper/src/executable.mjs new file mode 100755 index 0000000000..0931b4769d --- /dev/null +++ b/packages/workspace-helper/src/executable.mjs @@ -0,0 +1,399 @@ +#!/usr/bin/env node +import { randomUUID } from "node:crypto"; +import { constants, watch } from "node:fs"; +import { access, open, realpath, readdir, rename, stat, unlink } from "node:fs/promises"; +import path from "node:path"; +import readline from "node:readline"; + +const outsideMessage = "Access outside of workspace is not allowed"; +const imageTypes = new Map([ + [".png", "image/png"], + [".jpg", "image/jpeg"], + [".jpeg", "image/jpeg"], + [".gif", "image/gif"], + [".webp", "image/webp"], + [".svg", "image/svg+xml"], +]); +const operation = process.argv[2]; +let argumentsByName = new Map(); +const argument = (name, fallback) => argumentsByName.get(name) ?? fallback; +const root = await realpath("."); + +try { + if (!operation) throw new Error("Usage: workspace-helper "); + argumentsByName = parseArguments(operation, process.argv.slice(3)); + if (operation === "fs-stat") print(await fileStat(root, argument("--path", "."))); + else if (operation === "fs-list") print(await list(root, argument("--path", "."))); + else if (operation === "fs-read") await read(root, argument("--path")); + else if (operation === "fs-write") await write(root, argument("--path")); + else if (operation === "watch") await runWatcher(root); + else if (operation === "resolve-command") + print({ path: await resolveCommand(root, argument("--name")) }); + else if (operation === "describe") + print({ version: 1, capabilities: ["files", "watch", "resolve-command"] }); + else throw new Error(`Unknown workspace-helper command: ${operation}`); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} + +function parseArguments(command, argv) { + const allowed = new Set(allowedArguments(command)); + const parsed = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + if (!allowed.has(name)) throw new Error(`Unknown workspace-helper argument: ${name}`); + const value = argv[index + 1]; + if (value === undefined) throw new Error(`${name} requires a value`); + if (parsed.has(name)) throw new Error(`Duplicate workspace-helper argument: ${name}`); + parsed.set(name, value); + } + return parsed; +} + +function allowedArguments(command) { + if (command === "fs-write") { + return ["--path", "--expected-modified-at", "--expected-revision"]; + } + if (command === "fs-stat" || command === "fs-list" || command === "fs-read") { + return ["--path"]; + } + if (command === "resolve-command") return ["--name"]; + return []; +} + +async function resolveCommand(workspaceRoot, name) { + if (!name) throw new Error("--name is required"); + const candidates = commandCandidates(name); + if (name.includes("/") || (process.platform === "win32" && name.includes("\\"))) { + for (const candidateName of candidates) { + const candidate = path.isAbsolute(candidateName) + ? candidateName + : path.resolve(workspaceRoot, candidateName); + const resolved = await resolveExecutable(candidate); + if (resolved) return resolved; + } + return null; + } + const searchPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; + for (const directory of new Set([ + path.dirname(process.execPath), + ...searchPath.split(path.delimiter), + ])) { + for (const candidateName of candidates) { + const resolved = await resolveExecutable(path.join(directory, candidateName)); + if (resolved) return resolved; + } + } + return null; +} + +function commandCandidates(name) { + if (process.platform !== "win32") return [name]; + const extensions = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean); + const nameExtension = path.extname(name).toLowerCase(); + if (extensions.some((extension) => extension.toLowerCase() === nameExtension)) return [name]; + return [name, ...extensions.map((extension) => `${name}${extension}`)]; +} + +async function resolveExecutable(candidate) { + try { + await access(candidate, constants.X_OK); + return await realpath(candidate); + } catch { + return null; + } +} + +async function fileStat(workspaceRoot, relativePath) { + try { + const scoped = await resolveScoped(workspaceRoot, relativePath); + const info = await stat(scoped.absolute, { bigint: true }); + if (!info.isFile()) + return { status: "error", path: scoped.relative, error: "Requested path is not a file" }; + const type = await classify(scoped.absolute, Number(info.size)); + return { + status: "ready", + path: scoped.relative, + ...type, + size: Number(info.size), + modifiedAt: info.mtime.toISOString(), + revision: revision(info), + }; + } catch (error) { + if (isMissing(error)) return { status: "missing", path: relativePath }; + return { + status: "error", + path: relativePath, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +async function list(workspaceRoot, relativePath) { + const directory = await resolveScoped(workspaceRoot, relativePath); + const info = await stat(directory.absolute); + if (!info.isDirectory()) throw new Error("Requested path is not a directory"); + const entries = []; + for (const dirent of await readdir(directory.absolute, { withFileTypes: true })) { + try { + const child = await resolveScoped( + workspaceRoot, + path.posix.join(directory.relative, dirent.name), + ); + const childInfo = await stat(child.absolute); + entries.push({ + name: dirent.name, + path: child.relative, + kind: childInfo.isDirectory() ? "directory" : "file", + size: childInfo.size, + modifiedAt: childInfo.mtime.toISOString(), + }); + } catch (error) { + if (!isMissing(error) && error?.message !== outsideMessage) throw error; + } + } + entries.sort( + (left, right) => + Date.parse(right.modifiedAt) - Date.parse(left.modifiedAt) || + left.name.localeCompare(right.name), + ); + return { path: directory.relative, entries }; +} + +async function read(workspaceRoot, relativePath) { + const scoped = await resolveScoped(workspaceRoot, relativePath); + const handle = await open( + scoped.absolute, + process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW, + ); + try { + const info = await handle.stat(); + if (!info.isFile()) throw new Error("Requested path is not a file"); + for await (const chunk of handle.createReadStream({ + autoClose: false, + highWaterMark: 256 * 1024, + })) { + if (!process.stdout.write(chunk)) + await new Promise((resolve) => process.stdout.once("drain", resolve)); + } + } finally { + await handle.close(); + } +} + +async function write(workspaceRoot, relativePath) { + const scoped = await resolveScoped(workspaceRoot, relativePath); + const before = await stat(scoped.absolute, { bigint: true }).catch((error) => + isMissing(error) ? null : Promise.reject(error), + ); + if (!before) + return print({ status: "conflict", version: { status: "missing", path: relativePath } }); + if (!before.isFile()) return print({ status: "error", error: "Requested path is not a file" }); + const expectedRevision = argument("--expected-revision"); + const expectedModifiedAt = argument("--expected-modified-at"); + if ( + (expectedRevision && revision(before) !== expectedRevision) || + (!expectedRevision && expectedModifiedAt && before.mtime.toISOString() !== expectedModifiedAt) + ) { + return print({ status: "conflict", version: await fileStat(workspaceRoot, relativePath) }); + } + const temporary = path.join( + path.dirname(scoped.absolute), + `.${path.basename(scoped.absolute)}.paseo-${randomUUID()}.tmp`, + ); + let handle; + try { + handle = await open(temporary, "wx", Number(before.mode)); + for await (const chunk of process.stdin) await handle.write(chunk); + await handle.sync(); + await handle.close(); + handle = undefined; + const latest = await stat(scoped.absolute, { bigint: true }); + if (revision(latest) !== revision(before)) + return print({ status: "conflict", version: await fileStat(workspaceRoot, relativePath) }); + await rename(temporary, scoped.absolute); + const result = await stat(scoped.absolute, { bigint: true }); + print({ + status: "written", + modifiedAt: result.mtime.toISOString(), + size: Number(result.size), + revision: revision(result), + }); + } finally { + await handle?.close().catch(() => undefined); + await unlink(temporary).catch(() => undefined); + } +} + +async function runWatcher(workspaceRoot) { + const subscriptions = new Map(); + print({ type: "ready" }); + const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + try { + for await (const line of lines) { + const command = JSON.parse(line); + if (command.protocolVersion !== 1) throw new Error("Unsupported workspace-helper protocol"); + if ("root" in command) throw new Error("Watch subscriptions cannot supply a root"); + if (command.type === "close") break; + if (command.type === "unsubscribe") { + closeSubscription(subscriptions.get(command.id)); + subscriptions.delete(command.id); + print({ type: "unsubscribed", subscriptionId: command.id }); + continue; + } + if (command.type !== "subscribe") continue; + closeSubscription(subscriptions.get(command.id)); + const observation = { watchers: new Map(), timers: new Map() }; + subscriptions.set(command.id, observation); + const ignoredPaths = new Set(command.ignoredPaths ?? []); + for (const requested of command.paths) { + const scoped = await resolveScoped(workspaceRoot, requested); + const info = await stat(scoped.absolute).catch((error) => { + if (isMissing(error)) return null; + throw error; + }); + if (command.recursive === true && info?.isDirectory()) { + await watchDirectoryTree(observation, scoped, command.id, ignoredPaths); + continue; + } + const expectedName = path.basename(scoped.absolute); + const watcher = watch(path.dirname(scoped.absolute), (_event, filename) => { + if (filename != null && filename.toString() !== expectedName) return; + clearTimeout(observation.timers.get(scoped.relative)); + observation.timers.set( + scoped.relative, + setTimeout(() => { + observation.timers.delete(scoped.relative); + print({ type: "changed", subscriptionId: command.id, paths: [scoped.relative] }); + }, 50), + ); + }); + watcher.on("error", (error) => + print({ type: "error", subscriptionId: command.id, message: error.message }), + ); + observation.watchers.set(scoped.absolute, watcher); + } + print({ type: "subscribed", subscriptionId: command.id }); + } + } finally { + for (const observation of subscriptions.values()) closeSubscription(observation); + } +} + +function closeSubscription(observation) { + if (!observation) return; + for (const watcher of observation.watchers.values()) watcher.close(); + for (const timer of observation.timers.values()) clearTimeout(timer); +} + +async function watchDirectoryTree(observation, scoped, subscriptionId, ignoredPaths) { + if (observation.watchers.has(scoped.absolute)) return; + const watcher = watch(scoped.absolute, (_event, filename) => { + const name = filename?.toString(); + const changed = name ? path.join(scoped.absolute, name) : scoped.absolute; + const relative = normalize(path.relative(root, changed)); + if (isIgnored(relative, ignoredPaths)) return; + clearTimeout(observation.timers.get(relative)); + observation.timers.set( + relative, + setTimeout(() => { + observation.timers.delete(relative); + print({ type: "changed", subscriptionId, paths: [relative] }); + void addDiscoveredDirectories(observation, scoped, subscriptionId, ignoredPaths); + }, 50), + ); + }); + watcher.on("error", (error) => print({ type: "error", subscriptionId, message: error.message })); + observation.watchers.set(scoped.absolute, watcher); + await addDiscoveredDirectories(observation, scoped, subscriptionId, ignoredPaths); +} + +async function addDiscoveredDirectories(observation, scoped, subscriptionId, ignoredPaths) { + const entries = await readdir(scoped.absolute, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const absolute = path.join(scoped.absolute, entry.name); + const relative = normalize(path.relative(root, absolute)); + if (isIgnored(relative, ignoredPaths)) continue; + await watchDirectoryTree(observation, { absolute, relative }, subscriptionId, ignoredPaths); + } +} + +function isIgnored(relativePath, ignoredPaths) { + for (const ignored of ignoredPaths) { + if (relativePath === ignored || relativePath.startsWith(`${ignored}/`)) return true; + } + return false; +} + +async function resolveScoped(workspaceRoot, relativePath = ".") { + const normalizedRoot = path.resolve(workspaceRoot); + if (path.isAbsolute(relativePath)) throw new Error(outsideMessage); + const requested = path.resolve(normalizedRoot, relativePath); + if (!contains(normalizedRoot, requested)) throw new Error(outsideMessage); + const canonicalRoot = await realpath(normalizedRoot); + try { + const canonical = await realpath(requested); + if (!contains(canonicalRoot, canonical)) throw new Error(outsideMessage); + return { absolute: canonical, relative: normalize(path.relative(normalizedRoot, requested)) }; + } catch (error) { + if (!isMissing(error)) throw error; + const canonicalParent = await realpath(path.dirname(requested)); + if (!contains(canonicalRoot, canonicalParent)) + throw new Error(outsideMessage, { cause: error }); + return { absolute: requested, relative: normalize(path.relative(normalizedRoot, requested)) }; + } +} + +function contains(rootPath, candidate) { + const relative = path.relative(rootPath, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} +function normalize(relative) { + return relative === "" ? "." : relative.split(path.sep).join("/"); +} +function revision(info) { + return `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}`; +} +function isMissing(error) { + return ["ENOENT", "ENOTDIR", "ELOOP"].includes(error?.code); +} +function print(value) { + process.stdout.write(`${JSON.stringify({ protocolVersion: 1, ...value })}\n`); +} + +async function classify(file, size) { + const extension = path.extname(file).toLowerCase(); + if (imageTypes.has(extension)) + return { kind: "image", encoding: "binary", mimeType: imageTypes.get(extension) }; + const handle = await open(file, "r"); + try { + const sample = Buffer.alloc(Math.min(8192, size)); + const { bytesRead } = await handle.read(sample, 0, sample.length, 0); + const bytes = sample.subarray(0, bytesRead); + if (binary(bytes)) + return { kind: "binary", encoding: "binary", mimeType: "application/octet-stream" }; + return { + kind: "text", + encoding: "utf-8", + mimeType: extension === ".json" ? "application/json" : "text/plain", + }; + } finally { + await handle.close(); + } +} +function binary(bytes) { + if (bytes.length === 0) return false; + let suspicious = 0; + for (const byte of bytes) { + if (byte === 0) return true; + if ((byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) || byte === 127) suspicious++; + } + try { + new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return true; + } + return suspicious / bytes.length > 0.3; +} diff --git a/packages/workspace-helper/src/files.ts b/packages/workspace-helper/src/files.ts new file mode 100644 index 0000000000..6ab61899be --- /dev/null +++ b/packages/workspace-helper/src/files.ts @@ -0,0 +1,74 @@ +export type WorkspaceFileKind = "text" | "image" | "binary"; + +export type WorkspaceFileStat = + | { + status: "ready"; + path: string; + kind: WorkspaceFileKind; + encoding: "utf-8" | "binary"; + mimeType: string; + size: number; + modifiedAt: string; + revision: string; + } + | { status: "missing"; path: string } + | { status: "error"; path: string; error: string }; + +export interface WorkspaceDirectoryEntry { + name: string; + path: string; + kind: "file" | "directory"; + size: number; + modifiedAt: string; +} + +export interface WorkspaceDirectory { + path: string; + entries: WorkspaceDirectoryEntry[]; +} + +export interface WorkspaceFileRead { + path: string; + kind: WorkspaceFileKind; + encoding: "utf-8" | "binary"; + mimeType: string; + size: number; + modifiedAt: string; + revision: string; + chunks: WorkspaceFileContent; +} + +export interface WorkspaceFileContent extends AsyncIterable { + /** Stops an incomplete transfer and resolves only after its helper process exits. */ + cancel(): Promise; +} + +export type WorkspaceFileWriteResult = + | { status: "written"; modifiedAt: string; size: number; revision: string } + | { status: "conflict"; version: WorkspaceFileStat } + | { status: "error"; error: string }; + +export type WorkspaceWatchEvent = + | { type: "changed"; paths: string[] } + | { type: "overflow" } + | { type: "error"; error: string }; + +export interface WorkspaceFilesSubscription { + unsubscribe(): Promise; +} + +export interface WorkspaceFiles { + stat(path: string): Promise; + list(path: string): Promise; + read(path: string): Promise; + write(input: { + path: string; + contents: Uint8Array | AsyncIterable; + expectedModifiedAt?: string; + expectedRevision?: string; + }): Promise; + subscribe( + input: { paths: readonly string[]; recursive?: boolean; ignoredPaths?: readonly string[] }, + listener: (event: WorkspaceWatchEvent) => void, + ): Promise; +} diff --git a/packages/workspace-helper/src/index.ts b/packages/workspace-helper/src/index.ts new file mode 100644 index 0000000000..62459283c9 --- /dev/null +++ b/packages/workspace-helper/src/index.ts @@ -0,0 +1,3 @@ +export * from "./binding.js"; +export * from "./files.js"; +export { WORKSPACE_HELPER_PROTOCOL_VERSION, workspaceHelperExecutable } from "./location.js"; diff --git a/packages/workspace-helper/src/location.ts b/packages/workspace-helper/src/location.ts new file mode 100644 index 0000000000..2c03e3a813 --- /dev/null +++ b/packages/workspace-helper/src/location.ts @@ -0,0 +1,6 @@ +import { fileURLToPath } from "node:url"; + +export const WORKSPACE_HELPER_PROTOCOL_VERSION = 1 as const; +export const workspaceHelperExecutable = fileURLToPath( + new URL("./executable.mjs", import.meta.url), +); diff --git a/packages/workspace-helper/src/protocol.ts b/packages/workspace-helper/src/protocol.ts new file mode 100644 index 0000000000..63e0ab3b91 --- /dev/null +++ b/packages/workspace-helper/src/protocol.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; + +const version = { protocolVersion: z.literal(1) }; +const fileKind = z.enum(["text", "image", "binary"]); + +export const describeSchema = z.object({ + ...version, + version: z.literal(1), + capabilities: z.array(z.enum(["files", "watch", "resolve-command"])), +}); + +export const resolvedCommandSchema = z.object({ + path: z.string().nullable(), +}); + +const readyFile = z.object({ + status: z.literal("ready"), + path: z.string(), + kind: fileKind, + encoding: z.enum(["utf-8", "binary"]), + mimeType: z.string(), + size: z.number().int().nonnegative(), + modifiedAt: z.string(), + revision: z.string(), +}); + +const fileStatValueSchema = z.discriminatedUnion("status", [ + readyFile, + z.object({ status: z.literal("missing"), path: z.string() }), + z.object({ + status: z.literal("error"), + path: z.string(), + error: z.string(), + }), +]); + +export const fileStatSchema = fileStatValueSchema.and(z.object(version)).transform((result) => { + const { protocolVersion: _protocolVersion, ...value } = result; + return value; +}); + +export const directorySchema = z + .object({ + ...version, + path: z.string(), + entries: z.array( + z.object({ + name: z.string(), + path: z.string(), + kind: z.enum(["file", "directory"]), + size: z.number().int().nonnegative(), + modifiedAt: z.string(), + }), + ), + }) + .transform((result) => { + const { protocolVersion: _protocolVersion, ...value } = result; + return value; + }); + +export const writeResultSchema = z + .discriminatedUnion("status", [ + z.object({ + ...version, + status: z.literal("written"), + modifiedAt: z.string(), + size: z.number().int().nonnegative(), + revision: z.string(), + }), + z.object({ ...version, status: z.literal("conflict"), version: fileStatValueSchema }), + z.object({ ...version, status: z.literal("error"), error: z.string() }), + ]) + .transform((result) => { + const { protocolVersion: _protocolVersion, ...value } = result; + return value; + }); + +export const watchEventSchema = z.discriminatedUnion("type", [ + z.object({ ...version, type: z.literal("ready") }), + z.object({ ...version, type: z.literal("subscribed"), subscriptionId: z.string() }), + z.object({ ...version, type: z.literal("unsubscribed"), subscriptionId: z.string() }), + z.object({ + ...version, + type: z.literal("changed"), + subscriptionId: z.string(), + paths: z.array(z.string()), + }), + z.object({ ...version, type: z.literal("overflow"), subscriptionId: z.string() }), + z.object({ + ...version, + type: z.literal("error"), + subscriptionId: z.string().optional(), + message: z.string(), + }), +]); diff --git a/packages/workspace-helper/test/command-resolution.test.mjs b/packages/workspace-helper/test/command-resolution.test.mjs new file mode 100644 index 0000000000..87cd2e19bf --- /dev/null +++ b/packages/workspace-helper/test/command-resolution.test.mjs @@ -0,0 +1,33 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { expect, test } from "vitest"; + +const run = promisify(execFile); +const workspaceHelper = fileURLToPath(new URL("../src/executable.mjs", import.meta.url)); + +test.runIf(process.platform === "win32")("Windows command resolution honors PATHEXT", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-helper-windows-command-")); + try { + const executable = path.join(root, "paseo-pathext-fixture.CMD"); + await writeFile(executable, "@exit /b 0\r\n"); + const result = await run( + process.execPath, + [workspaceHelper, "resolve-command", "--name", "paseo-pathext-fixture"], + { + cwd: root, + env: { ...process.env, PATH: root, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }, + ); + expect(JSON.parse(result.stdout)).toEqual({ + protocolVersion: 1, + path: await realpath(executable), + }); + } finally { + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); diff --git a/packages/workspace-helper/test/fixtures/adversarial-helper.mjs b/packages/workspace-helper/test/fixtures/adversarial-helper.mjs new file mode 100644 index 0000000000..c4303bded3 --- /dev/null +++ b/packages/workspace-helper/test/fixtures/adversarial-helper.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +import readline from "node:readline"; + +const mode = process.argv[2]; +const operation = process.argv[3]; + +if (operation === "fs-stat") { + process.stdout.write( + `${JSON.stringify({ + protocolVersion: 1, + status: "ready", + path: "large.bin", + kind: "binary", + encoding: "binary", + mimeType: "application/octet-stream", + size: Number.MAX_SAFE_INTEGER, + modifiedAt: "2026-01-01T00:00:00.000Z", + revision: "fixture-revision", + })}\n`, + ); +} else if (operation === "fs-read" && mode === "endless-read") { + const chunk = Buffer.alloc(256 * 1024, 0xa5); + const write = () => { + while (process.stdout.write(chunk)) continue; + process.stdout.once("drain", write); + }; + write(); +} else if (operation === "watch") { + const safetyExit = setTimeout(() => process.exit(86), 4_000); + const keepAlive = setInterval(() => undefined, 1_000); + const finish = () => { + clearTimeout(safetyExit); + clearInterval(keepAlive); + }; + process.once("exit", finish); + if (mode === "malformed-watch") { + process.stdout.write("not-json\n"); + } else if (mode !== "no-ready") { + print({ type: "ready" }); + const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + for await (const line of lines) { + handleWatchCommand(JSON.parse(line), finish); + } + } +} else { + process.stderr.write(`Unsupported fixture operation: ${mode} ${operation}\n`); + process.exitCode = 1; +} + +function print(value) { + process.stdout.write(`${JSON.stringify({ protocolVersion: 1, ...value })}\n`); +} + +function handleWatchCommand(command, finish) { + if (command.type === "subscribe" && mode !== "no-subscribed") { + print({ type: "subscribed", subscriptionId: command.id }); + if (mode === "overflow") print({ type: "overflow", subscriptionId: command.id }); + return; + } + if (command.type === "unsubscribe" && mode !== "no-unsubscribed") { + print({ type: "unsubscribed", subscriptionId: command.id }); + return; + } + if (command.type !== "close") return; + if (mode === "resists-close") process.on("SIGTERM", () => undefined); + else finish(); +} diff --git a/packages/workspace-helper/test/standalone.test.mjs b/packages/workspace-helper/test/standalone.test.mjs new file mode 100644 index 0000000000..14e6def7a6 --- /dev/null +++ b/packages/workspace-helper/test/standalone.test.mjs @@ -0,0 +1,103 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { promisify } from "node:util"; + +const run = promisify(execFile); +const npmCommand = process.platform === "win32" ? process.execPath : "npm"; +const npmPrefixArgs = + process.platform === "win32" ? [requireEnvironmentVariable("npm_execpath")] : []; +const packageRoot = path.resolve(import.meta.dirname, ".."); + +function requireEnvironmentVariable(name) { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required to invoke npm on Windows`); + return value; +} + +function runNpm(args, options) { + return run(npmCommand, [...npmPrefixArgs, ...args], options); +} + +test("packed helper works from a fresh project without monorepo source fallback", async (t) => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-helper-standalone-")); + t.after(() => rm(root, { recursive: true, force: true })); + const packDirectory = path.join(root, "packs"); + const project = path.join(root, "project"); + await Promise.all([mkdir(packDirectory), mkdir(project)]); + await runNpm(["run", "build"], { cwd: packageRoot }); + const packed = JSON.parse( + ( + await runNpm(["pack", "--json", "--pack-destination", packDirectory], { + cwd: packageRoot, + }) + ).stdout, + )[0].filename; + await writeFile( + path.join(project, "package.json"), + JSON.stringify({ + type: "module", + dependencies: { "@getpaseo/workspace-helper": `file:${path.join(packDirectory, packed)}` }, + }), + ); + await runNpm(["install", "--ignore-scripts"], { cwd: project }); + await writeFile(path.join(project, "notes.txt"), "before\n"); + + const imported = JSON.parse( + ( + await run( + process.execPath, + [ + "--input-type=module", + "-e", + "import('@getpaseo/workspace-helper').then(m=>console.log(JSON.stringify({version:m.WORKSPACE_HELPER_PROTOCOL_VERSION,bin:m.workspaceHelperExecutable})))", + ], + { cwd: project }, + ) + ).stdout, + ); + assert.equal(imported.version, 1); + assert.match(imported.bin, /node_modules\/@getpaseo\/workspace-helper\/dist\/executable\.mjs$/u); + const bin = path.join(project, "node_modules/.bin/paseo-workspace-helper"); + const described = JSON.parse((await run(bin, ["describe"], { cwd: project })).stdout); + assert.deepEqual(described, { + protocolVersion: 1, + version: 1, + capabilities: ["files", "watch", "resolve-command"], + }); + assert.equal( + JSON.parse((await run(bin, ["fs-stat", "--path", "notes.txt"], { cwd: project })).stdout) + .status, + "ready", + ); + + const watch = await import("node:child_process").then(({ spawn }) => + spawn(bin, ["watch"], { cwd: project, stdio: ["pipe", "pipe", "pipe"] }), + ); + const output = []; + watch.stdout.setEncoding("utf8"); + watch.stdout.on("data", (chunk) => output.push(chunk)); + watch.stdin.write( + `${JSON.stringify({ protocolVersion: 1, type: "subscribe", id: "proof", paths: ["notes.txt"], recursive: false, ignoredPaths: [] })}\n`, + ); + await until(() => output.join("").includes('"type":"subscribed"')); + await writeFile(path.join(project, "notes.txt"), "after\n"); + await until(() => output.join("").includes('"type":"changed"')); + watch.stdin.end(`${JSON.stringify({ protocolVersion: 1, type: "close" })}\n`); + await new Promise((resolve, reject) => { + watch.once("error", reject); + watch.once("exit", resolve); + }); + assert.equal(await readFile(path.join(project, "notes.txt"), "utf8"), "after\n"); +}); + +async function until(condition) { + const deadline = Date.now() + 5_000; + while (!condition()) { + if (Date.now() > deadline) throw new Error("Timed out waiting for helper watch event"); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} diff --git a/packages/workspace-helper/test/workspace-helper.posix.test.ts b/packages/workspace-helper/test/workspace-helper.posix.test.ts new file mode 100644 index 0000000000..c6809cd9b7 --- /dev/null +++ b/packages/workspace-helper/test/workspace-helper.posix.test.ts @@ -0,0 +1,534 @@ +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterEach, describe, expect, test } from "vitest"; + +import type { WorkspaceWatchEvent } from "../src/files.js"; +import { bindWorkspaceHelper, type WorkspaceHelperProcess } from "../src/binding.js"; + +const posixDescribe = describe.runIf(process.platform !== "win32"); +const cleanupRoots: string[] = []; +const adversarialHelper = fileURLToPath( + new URL("./fixtures/adversarial-helper.mjs", import.meta.url), +); +const workspaceHelper = fileURLToPath(new URL("../src/executable.mjs", import.meta.url)); + +afterEach(async () => { + await Promise.all( + cleanupRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +async function fixture() { + const parent = await mkdtemp(path.join(tmpdir(), "paseo-workspace-helper-")); + cleanupRoots.push(parent); + const root = path.join(parent, "workspace"); + await mkdir(root); + const client = bindWorkspaceHelper({ + command: [process.execPath, workspaceHelper], + launch: (argv) => launchTestProcess(argv, root), + }); + return { client, parent, root }; +} + +async function collect(chunks: AsyncIterable): Promise { + const collected: Buffer[] = []; + for await (const chunk of chunks) collected.push(Buffer.from(chunk)); + return Buffer.concat(collected); +} + +describe("workspace-helper cross-platform observation", () => { + test("successful client writes notify matching live subscriptions", async () => { + const { client, root } = await fixture(); + await mkdir(path.join(root, "ignored")); + await writeFile(path.join(root, "notes.txt"), "before\n"); + await writeFile(path.join(root, "other.txt"), "other\n"); + await writeFile(path.join(root, "ignored", "secret.txt"), "secret\n"); + const exactEvents: WorkspaceWatchEvent[] = []; + const recursiveEvents: WorkspaceWatchEvent[] = []; + const unrelatedEvents: WorkspaceWatchEvent[] = []; + const subscriptions = await Promise.all([ + client.files.subscribe({ paths: ["notes.txt"] }, (event) => exactEvents.push(event)), + client.files.subscribe( + { paths: ["."], recursive: true, ignoredPaths: ["ignored"] }, + (event) => recursiveEvents.push(event), + ), + client.files.subscribe({ paths: ["other.txt"] }, (event) => unrelatedEvents.push(event)), + ]); + + try { + await expect( + client.files.write({ path: "notes.txt", contents: Buffer.from("after\n") }), + ).resolves.toMatchObject({ status: "written" }); + expect(exactEvents).toContainEqual({ type: "changed", paths: ["notes.txt"] }); + expect(recursiveEvents).toContainEqual({ type: "changed", paths: ["notes.txt"] }); + expect(unrelatedEvents).not.toContainEqual({ type: "changed", paths: ["notes.txt"] }); + + await expect( + client.files.write({ + path: "ignored/secret.txt", + contents: Buffer.from("hidden\n"), + }), + ).resolves.toMatchObject({ status: "written" }); + expect(recursiveEvents).not.toContainEqual({ + type: "changed", + paths: ["ignored/secret.txt"], + }); + } finally { + await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe())); + await client.close(); + } + }); + + test("direct filesystem writes still reach the native watcher", async () => { + const { client, root } = await fixture(); + await writeFile(path.join(root, "watched.txt"), "before\n"); + let resolveChanged!: (paths: string[]) => void; + const changed = new Promise((resolve) => { + resolveChanged = resolve; + }); + const subscription = await client.files.subscribe({ paths: ["watched.txt"] }, (event) => { + if (event.type === "changed") resolveChanged(event.paths); + }); + + try { + await writeFile(path.join(root, "watched.txt"), "after\n"); + await expect(eventWithin(changed, "native watch change")).resolves.toEqual(["watched.txt"]); + } finally { + await subscription.unsubscribe(); + await client.close(); + } + }); +}); + +posixDescribe("workspace-helper public capability", () => { + test("executable describe reports the exact versioned capability contract", async () => { + const child = spawn(process.execPath, [workspaceHelper, "describe"], { + stdio: ["ignore", "pipe", "pipe"], + }); + const [stdout, stderr, exit] = await Promise.all([ + collect(child.stdout), + collect(child.stderr), + childProcess(child).exited, + ]); + expect(exit, stderr.toString()).toEqual({ code: 0, signal: null }); + expect(JSON.parse(stdout.toString())).toEqual({ + protocolVersion: 1, + version: 1, + capabilities: ["files", "watch", "resolve-command"], + }); + }); + + test("watch framing accepts split frames and rejects malformed input", async () => { + const parent = await mkdtemp(path.join(tmpdir(), "paseo-workspace-helper-framing-")); + cleanupRoots.push(parent); + const child = spawn(process.execPath, [workspaceHelper, "watch"], { + cwd: parent, + stdio: ["pipe", "pipe", "pipe"], + }); + const stdout = collect(child.stdout); + const stderr = collect(child.stderr); + child.stdin.write('{"protocolVersion":1,"type":"subscribe","id":"split","paths":["."],'); + child.stdin.end('"recursive":false,"ignoredPaths":[]}\nnot-json\n'); + await expect(childProcess(child).exited).resolves.toEqual({ code: 1, signal: null }); + expect((await stdout).toString()).toContain('"type":"ready"'); + expect((await stdout).toString()).toContain('"type":"subscribed"'); + expect((await stderr).toString()).toMatch(/JSON|Unexpected token/); + }); + + test("overflow frames propagate through the typed binding", async () => { + const recorded = await recordedClient("overflow"); + try { + const event = new Promise((resolve) => { + void recorded.client.files.subscribe({ paths: ["notes.txt"] }, resolve); + }); + await expect(event).resolves.toEqual({ type: "overflow" }); + } finally { + await recorded.cleanup(); + } + }); + + test("client sends only workspace-relative helper paths", async () => { + const { client } = await fixture(); + expect(() => client.files.stat("/tmp/outside")).toThrow( + "Workspace helper path must be relative", + ); + expect(() => client.files.list("../outside")).toThrow("Workspace helper path must be relative"); + await expect( + client.files.subscribe({ paths: ["C:/outside"] }, () => undefined), + ).rejects.toThrow("Workspace helper path must be relative"); + await client.close(); + }); + + test("watch rejects a second root authority supplied by a subscriber", async () => { + const parent = await mkdtemp(path.join(tmpdir(), "paseo-workspace-helper-root-")); + cleanupRoots.push(parent); + const trustedRoot = path.join(parent, "trusted"); + const untrustedRoot = path.join(parent, "untrusted"); + await mkdir(trustedRoot); + await mkdir(untrustedRoot); + await writeFile(path.join(untrustedRoot, "outside.txt"), "outside\n"); + const child = spawn(process.execPath, [workspaceHelper, "watch"], { + cwd: trustedRoot, + stdio: ["pipe", "pipe", "pipe"], + }); + const stdout = collect(child.stdout); + const stderr = collect(child.stderr); + child.stdin.end( + `${JSON.stringify({ + protocolVersion: 1, + type: "subscribe", + id: "malicious", + root: untrustedRoot, + paths: ["outside.txt"], + })}\n${JSON.stringify({ protocolVersion: 1, type: "close" })}\n`, + ); + + await expect( + new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + }), + ).resolves.toEqual({ code: 1, signal: null }); + expect((await stderr).toString()).toContain("Watch subscriptions cannot supply a root"); + expect((await stdout).toString()).not.toContain("subscribed"); + }); + + test("multi-path watches preserve exact paths for matching basenames", async () => { + const { client, root } = await fixture(); + await mkdir(path.join(root, "a")); + await mkdir(path.join(root, "b")); + await writeFile(path.join(root, "a", "file.txt"), "a\n"); + await writeFile(path.join(root, "b", "file.txt"), "b\n"); + let resolveChanged!: (paths: string[]) => void; + const changed = new Promise((resolve) => { + resolveChanged = resolve; + }); + const subscription = await client.files.subscribe( + { paths: ["a/file.txt", "b/file.txt"] }, + (event) => { + if (event.type === "changed") resolveChanged(event.paths); + }, + ); + + await writeFile(path.join(root, "a", "file.txt"), "changed\n"); + await expect(changed).resolves.toEqual(["a/file.txt"]); + await subscription.unsubscribe(); + await client.close(); + }); + + test("a clean watcher restart does not replay a stale file version", async () => { + const { client, root } = await fixture(); + const first = await client.files.subscribe({ paths: ["later.txt"] }, () => undefined); + await first.unsubscribe(); + const events: WorkspaceWatchEvent[] = []; + let resolveChanged!: () => void; + const changed = new Promise((resolve) => { + resolveChanged = resolve; + }); + const second = await client.files.subscribe({ paths: ["later.txt"] }, (event) => { + events.push(event); + if (event.type === "changed") resolveChanged(); + }); + + expect(events).toEqual([]); + await writeFile(path.join(root, "later.txt"), "created\n"); + await changed; + expect(events).toContainEqual({ type: "changed", paths: ["later.txt"] }); + + await second.unsubscribe(); + await client.close(); + }); + + test("a helper crash relaunches the watcher and replays live subscriptions", async () => { + const parent = await mkdtemp(path.join(tmpdir(), "paseo-workspace-helper-replay-")); + cleanupRoots.push(parent); + const root = path.join(parent, "workspace"); + await mkdir(root); + await writeFile(path.join(root, "watched.txt"), "before\n"); + const watcherChildren: ReturnType[] = []; + let resolveSecondSubscription!: () => void; + const secondSubscription = new Promise((resolve) => { + resolveSecondSubscription = resolve; + }); + const client = bindWorkspaceHelper({ + command: [process.execPath, workspaceHelper], + launch: async (argv) => { + const child = spawn(argv[0], argv.slice(1), { + cwd: root, + stdio: ["pipe", "pipe", "pipe"], + }); + if (argv.includes("watch")) { + watcherChildren.push(child); + if (watcherChildren.length === 2) { + child.stdout.on("data", (chunk) => { + if (chunk.toString().includes('"type":"subscribed"')) { + resolveSecondSubscription(); + } + }); + } + } + return childProcess(child); + }, + }); + let resolveChanged!: (paths: string[]) => void; + const changed = new Promise((resolve) => { + resolveChanged = resolve; + }); + const subscription = await client.files.subscribe({ paths: ["watched.txt"] }, (event) => { + if (event.type === "changed") resolveChanged(event.paths); + }); + + watcherChildren[0]?.kill("SIGKILL"); + await secondSubscription; + await writeFile(path.join(root, "watched.txt"), "after\n"); + + await expect(changed).resolves.toEqual(["watched.txt"]); + await subscription.unsubscribe(); + await client.close(); + }); + + test("cancelling a partially-consumed read waits for the helper to exit", async () => { + const parent = await mkdtemp(path.join(tmpdir(), "paseo-workspace-helper-cancel-")); + cleanupRoots.push(parent); + const children: ReturnType[] = []; + const exits: WorkspaceHelperProcess["exited"][] = []; + const client = bindWorkspaceHelper({ + command: [process.execPath, adversarialHelper, "endless-read"], + launch: async (argv): Promise => { + const child = spawn(argv[0], argv.slice(1), { + cwd: parent, + stdio: ["pipe", "pipe", "pipe"], + }); + children.push(child); + const exited = new Promise>((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + }); + exits.push(exited); + return { + stdin: child.stdin, + stdout: child.stdout, + stderr: child.stderr, + exited, + kill: (signal) => child.kill(signal), + }; + }, + }); + + try { + const file = await client.files.read("large.bin"); + const iterator = file.chunks[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toMatchObject({ done: false }); + await file.chunks.cancel(); + await expect(exits[1]).resolves.toMatchObject({ signal: "SIGTERM" }); + expect(() => process.kill(children[1]?.pid ?? 0, 0)).toThrow(); + + const returned = await client.files.read("large.bin"); + for await (const _chunk of returned.chunks) break; + await expect(exits[3]).resolves.toMatchObject({ signal: "SIGTERM" }); + expect(() => process.kill(children[3]?.pid ?? 0, 0)).toThrow(); + } finally { + for (const child of children) child.kill("SIGKILL"); + await client.close(); + } + }); + + test("malformed watcher output rejects only after terminating the helper", async () => { + const recorded = await recordedClient("malformed-watch"); + try { + await expect( + recorded.client.files.subscribe({ paths: ["notes.txt"] }, () => undefined), + ).rejects.toThrow(/JSON|Unexpected token/); + await expect(recorded.exits[0]).resolves.toMatchObject({ signal: "SIGTERM" }); + expect(() => process.kill(recorded.pids[0] ?? 0, 0)).toThrow(); + } finally { + await recorded.cleanup(); + } + }); + + test.each([ + ["no-ready", "ready acknowledgement timed out"], + ["no-subscribed", "subscribe acknowledgement timed out"], + ["no-unsubscribed", "unsubscribe acknowledgement timed out"], + ])("%s watcher acknowledgements fail after bounded cleanup", async (mode, expectedError) => { + const recorded = await recordedClient(mode); + try { + if (mode === "no-unsubscribed") { + const subscription = await recorded.client.files.subscribe( + { paths: ["notes.txt"] }, + () => undefined, + ); + await expect(subscription.unsubscribe()).rejects.toThrow(expectedError); + } else { + await expect( + recorded.client.files.subscribe({ paths: ["notes.txt"] }, () => undefined), + ).rejects.toThrow(expectedError); + } + await expect(recorded.exits[0]).resolves.toMatchObject({ signal: "SIGTERM" }); + expect(() => process.kill(recorded.pids[0] ?? 0, 0)).toThrow(); + } finally { + await recorded.cleanup(); + } + }); + + test("watcher close escalates through TERM to KILL before unsubscribe settles", async () => { + const recorded = await recordedClient("resists-close"); + try { + const subscription = await recorded.client.files.subscribe( + { paths: ["notes.txt"] }, + () => undefined, + ); + await subscription.unsubscribe(); + await expect(recorded.exits[0]).resolves.toMatchObject({ signal: "SIGKILL" }); + expect(() => process.kill(recorded.pids[0] ?? 0, 0)).toThrow(); + } finally { + await recorded.cleanup(); + } + }); + + test("lists, streams, writes optimistically, confines symlinks, and observes changes", async () => { + const { client, parent, root } = await fixture(); + const binary = Buffer.alloc(700_000, 0xa5); + await writeFile(path.join(root, "large.bin"), binary); + await writeFile(path.join(root, "notes.txt"), "before\n"); + await writeFile(path.join(root, "upload.bin"), new Uint8Array()); + + const listing = await client.files.list("."); + expect(listing.entries.map((entry) => entry.name).sort()).toEqual([ + "large.bin", + "notes.txt", + "upload.bin", + ]); + + const streamed = await client.files.read("large.bin"); + expect(streamed).toMatchObject({ path: "large.bin", kind: "binary", size: binary.byteLength }); + expect(await collect(streamed.chunks)).toEqual(binary); + + const upload = await client.files.write({ + path: "upload.bin", + contents: (async function* () { + for (let offset = 0; offset < binary.byteLength; offset += 64 * 1024) { + yield binary.subarray(offset, Math.min(offset + 64 * 1024, binary.byteLength)); + } + })(), + }); + expect(upload).toMatchObject({ status: "written", size: binary.byteLength }); + expect(await readFile(path.join(root, "upload.bin"))).toEqual(binary); + + const initial = await client.files.stat("notes.txt"); + expect(initial).toMatchObject({ status: "ready", path: "notes.txt", size: 7 }); + if (initial.status !== "ready") throw new Error("Expected notes.txt to exist"); + + let resolveChanged!: () => void; + const changed = new Promise((resolve) => { + resolveChanged = resolve; + }); + const changeSubscription = await client.files.subscribe({ paths: ["notes.txt"] }, (event) => { + if (event.type === "changed" && event.paths.includes("notes.txt")) resolveChanged(); + }); + const subscription = await client.files.subscribe({ paths: ["notes.txt"] }, () => undefined); + const written = await client.files.write({ + path: "notes.txt", + contents: Buffer.from("after\n"), + expectedRevision: initial.revision, + }); + expect(written).toMatchObject({ status: "written", size: 6 }); + await expect(changed).resolves.toBeUndefined(); + expect(await readFile(path.join(root, "notes.txt"), "utf8")).toBe("after\n"); + + const conflict = await client.files.write({ + path: "notes.txt", + contents: Buffer.from("stale\n"), + expectedRevision: initial.revision, + }); + expect(conflict).toMatchObject({ status: "conflict" }); + + await writeFile(path.join(parent, "outside.txt"), "secret\n"); + await symlink(path.join(parent, "outside.txt"), path.join(root, "escape.txt")); + await expect(client.files.read("escape.txt")).rejects.toThrow( + "Access outside of workspace is not allowed", + ); + + await subscription.unsubscribe(); + await changeSubscription.unsubscribe(); + await client.close(); + }, 15_000); +}); + +async function recordedClient(mode: string) { + const root = await mkdtemp(path.join(tmpdir(), `paseo-workspace-helper-${mode}-`)); + cleanupRoots.push(root); + const children: ReturnType[] = []; + const exits: WorkspaceHelperProcess["exited"][] = []; + const pids: number[] = []; + const client = bindWorkspaceHelper({ + command: [process.execPath, adversarialHelper, mode], + launch: async (argv): Promise => { + const child = spawn(argv[0], argv.slice(1), { + cwd: root, + stdio: ["pipe", "pipe", "pipe"], + }); + const exited = new Promise>((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + }); + children.push(child); + exits.push(exited); + if (child.pid) pids.push(child.pid); + return { + stdin: child.stdin, + stdout: child.stdout, + stderr: child.stderr, + exited, + kill: (signal) => child.kill(signal), + }; + }, + }); + return { + client, + exits, + pids, + async cleanup() { + for (const child of children) child.kill("SIGKILL"); + await Promise.allSettled(exits); + await client.close().catch(() => undefined); + }, + }; +} + +async function launchTestProcess( + argv: readonly [string, ...string[]], + cwd: string, +): Promise { + const child = spawn(argv[0], argv.slice(1), { cwd, stdio: ["pipe", "pipe", "pipe"] }); + return childProcess(child); +} + +function childProcess(child: ReturnType): WorkspaceHelperProcess { + return { + stdin: child.stdin, + stdout: child.stdout, + stderr: child.stderr, + exited: new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + }), + kill: (signal) => child.kill(signal), + }; +} + +function eventWithin(event: Promise, label: string, timeoutMs = 5_000): Promise { + let timer: ReturnType | undefined; + return Promise.race([ + event, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), timeoutMs); + }), + ]).finally(() => { + if (timer) clearTimeout(timer); + }); +} diff --git a/packages/workspace-helper/tsconfig.json b/packages/workspace-helper/tsconfig.json new file mode 100644 index 0000000000..8984b07200 --- /dev/null +++ b/packages/workspace-helper/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "noEmit": false, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/workspace-runtime-contract/README.md b/packages/workspace-runtime-contract/README.md new file mode 100644 index 0000000000..1a8d335395 --- /dev/null +++ b/packages/workspace-runtime-contract/README.md @@ -0,0 +1,56 @@ +# Paseo command workspace runtime contract v1 + +Implement this contract to add a trusted workspace runtime without importing Paseo server code. Use the schemas and types from `@getpaseo/workspace-runtime-contract`; the tested byte examples are in [`examples/v1.json`](examples/v1.json). + +The command-runtime protocol and workspace-helper protocol have independent version fields. A +Paseo release pins compatible versions of this package and `@getpaseo/workspace-helper`, then +verifies both `describe` responses. Runtime packages depend on both exact compatible versions and +bundle the official helper bin. Runtime authors do not reimplement its protocol. + +## Commands + +Your configured command receives one operation: + +```text +describe +create --workspace-id +inspect --workspace-id +exec --workspace-id +signal --workspace-id --exec-id --signal +pause --workspace-id +resume --workspace-id +destroy --workspace-id +reconcile +``` + +`describe` writes `CommandRuntimeDescribeResponse` JSON to stdout. Lifecycle commands read one `CommandRuntimeLifecycleRequest` JSON value from stdin and write one `CommandRuntimeLifecycleResponse` JSON value to stdout. `create` and `resume` return `state`; `inspect` returns `inspection`; `pause`, `destroy`, and `reconcile` return `ok`. Diagnostics go to stderr. A non-zero wrapper exit means the operation failed. + +Paseo sends options from trusted daemon configuration and a stable `runtimeInstanceId` for shared-resource ownership. Runtime implementations decide how that opaque instance token maps to their resource system. Secrets never belong in argv or temporary request files. The runtime must reject any `protocolVersion` other than `1` with a clear stderr diagnostic and non-zero exit. Every public v1 object schema is strict at every nested object boundary, so unknown keys fail instead of being stripped. Extensibility belongs only in the explicit `options`, workload `env`, and lifecycle-environment maps. The optional create `purpose: "discovery"` field marks a short-lived environment-discovery workspace without naming the higher-level consumer. Every `create` response must set `materializedFreshContent`: `true` only when that call created fresh workspace content/resources on which repo setup is permitted, and `false` when it adopted or reused existing content. `resume` state responses omit it. + +`project.source` is either a directory visible to the runtime wrapper or a Git URL plus a required revision string and optional subdirectory. An empty Git revision selects the remote's default state. Paseo derives this source before invoking the runtime; `placement.cwd` never overrides it. A `discovery` create must expose the same runtime environment as a user workspace but must not execute repository setup. The checked-in JSON example uses this purpose so wrappers can lock its exact bytes. + +A runtime owns the meaning of its `options`. Keep runtime-specific mount, container, VM, or supervisor authority out of lifecycle state and placement. + +## Exec file descriptors + +The workload owns fd 0, 1, and 2. Preserve their normal stdin, stdout, stderr, EOF, exit-code, and signal behavior. + +Paseo writes a single newline-terminated `spawn` envelope to fd 3. For pipes, it then closes fd 3. For PTY, fd 3 stays open and carries newline-delimited `resize` and `signal` controls. Validate a resize before changing PTY state; reject an invalid frame without launching, resizing, or signaling anything. Acknowledge every accepted resize on fd 4 with the same id before consuming following workload input. + +fd 4 carries newline-delimited process events for both modes. Its lifecycle is exactly `started` -> `eof` -> `exit`. Send `started` after the execution identity can be signaled; Paseo holds early signals until then. A PTY may send `resized` acknowledgements only after `started` and before `eof`; pipes may never send them. Duplicate, out-of-order, wrong-mode, or post-`exit` events are fatal. Close fd 4 after the single authoritative `exit`. An `error` event fails the process. A frame may arrive in partial chunks, but every frame must end in a newline. Malformed frames, a partial frame at EOF, and wrapper exit without the complete lifecycle are fatal. Paseo rejects a protocol failure only after bounded cleanup of that exact execution. PTY output remains raw workload bytes on fd 1. Closing fd 3 is the PTY control EOF; it is not another JSON control frame. + +The authoritative fd 4 `exit` preserves the workload code or terminating signal; the wrapper mirrors it when possible. Forward `SIGINT`, `SIGTERM`, and `SIGHUP` received by the wrapper to the workload process group. The separate `signal` command is the bounded cleanup path for `SIGKILL` and wrapper failure. It is idempotent, identifies only the live workload owned by `workspace-id` plus the opaque `exec-id`, and returns only after that workload is gone. Before authoritative `started`, a missing identity is not ready and must fail; after authoritative completion it is already clean. Never reuse an execution identity. + +## Lifecycle and ownership + +`workspaceId` is the reconciliation and ownership key. `create`, `pause`, `resume`, and `destroy` are idempotent. `inspect` is authoritative after either process restarts. `spawn` fails unless inspection is `ready`. `pause` preserves workspace state; `destroy` removes only resources owned by that workspace. `reconcile` may remove orphaned owned resources and must reject ownership mismatches. + +Paseo addresses only `workspaceId`. The runtime keeps physical placement private and validates the optional workspace-relative `cwd` once, including traversal and symlink escapes; omission means workspace root. It executes `argv` directly, never as an implicit shell string, with exactly the provided workload environment. Do not inherit the wrapper or daemon environment. Lifecycle state contains no root, revision, execution domain, container name, or supervisor state. Public placement is descriptive compatibility data only: `cwd` is never execution authority. + +Every runtime must provide the compatible `paseo-workspace-helper` executable on its workload `PATH`. Paseo launches it as an ordinary workload with purpose `workspace-helper`, already rooted at the private workspace placement. The helper receives only relative `--path` values and uses its process cwd (`.`) as confinement authority; it has no `--root` option. Runtime authors do not implement file, watch, Git, provider, script, or agent APIs: the helper handles structured files and watching, while Paseo runs Git, providers, and scripts through `exec`. + +## Compatibility + +Version 1 targets macOS/Linux hosts and POSIX runtime environments. Unknown v1 fields are rejected; future fields require a new protocol version. A semantic change to framing, fd ownership, lifecycle, or cleanup also increments the protocol version. Paseo fails closed on a version mismatch; it never falls back to host execution. + +The private `@getpaseo/fixture-workspace-runtime` package is the executable contract fixture. It records the validated create input, can copy a directory source into owned storage, exposes deterministic failure and placement options, and runs workloads through both pipe and PTY framing. It is test infrastructure, not a production runtime or a second schema owner; all validation comes from this package's exported schemas. diff --git a/packages/workspace-runtime-contract/examples/v1.json b/packages/workspace-runtime-contract/examples/v1.json new file mode 100644 index 0000000000..7d42acd3c9 --- /dev/null +++ b/packages/workspace-runtime-contract/examples/v1.json @@ -0,0 +1,100 @@ +{ + "describeResponse": { + "protocolVersion": 1, + "modes": ["pipes", "pty"], + "reconcile": true + }, + "lifecycleRequest": { + "protocolVersion": 1, + "runtimeInstanceId": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "input": { + "workspaceId": "workspace-01", + "project": { + "projectId": "project-01", + "source": { + "kind": "git", + "url": "https://example.test/acme/project.git", + "revision": "main", + "subdirectory": "packages/app" + } + }, + "placement": { "kind": "existing" }, + "purpose": "discovery" + }, + "options": {} + }, + "lifecycleResponse": { + "protocolVersion": 1, + "type": "state", + "state": { + "workspaceId": "workspace-01", + "lifecycle": "ready" + }, + "placement": { "cwd": "/workspace" }, + "materializedFreshContent": true + }, + "pipeSpawnControl": { + "type": "spawn", + "protocolVersion": 1, + "runtimeInstanceId": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "argv": ["/bin/sh", "-c", "printf hello"], + "cwd": "packages/app", + "env": { "PATH": "/usr/bin:/bin" }, + "purpose": { "kind": "workspace-script" }, + "options": {}, + "execId": "exec-pipe-01", + "stdio": { "kind": "pipes" } + }, + "ptySpawnControl": { + "type": "spawn", + "protocolVersion": 1, + "runtimeInstanceId": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "argv": ["/bin/sh", "-c", "printf hello"], + "cwd": "packages/app", + "env": { "PATH": "/usr/bin:/bin" }, + "purpose": { "kind": "workspace-script" }, + "options": {}, + "execId": "exec-pty-01", + "stdio": { "kind": "pty", "rows": 24, "cols": 80, "term": "xterm-256color" } + }, + "resizeControl": { + "type": "resize", + "protocolVersion": 1, + "id": 1, + "rows": 40, + "cols": 120 + }, + "signalControl": { + "type": "signal", + "protocolVersion": 1, + "signal": "SIGTERM" + }, + "startedEvent": { "type": "started", "protocolVersion": 1 }, + "eofEvent": { "type": "eof", "protocolVersion": 1 }, + "resizedEvent": { "type": "resized", "protocolVersion": 1, "id": 1 }, + "exitEvent": { + "type": "exit", + "protocolVersion": 1, + "code": 0, + "signal": null + }, + "errorEvent": { + "type": "error", + "protocolVersion": 1, + "message": "runtime control failed" + }, + "bytes": { + "describeResponse": "{\"protocolVersion\":1,\"modes\":[\"pipes\",\"pty\"],\"reconcile\":true}\n", + "lifecycleRequest": "{\"protocolVersion\":1,\"runtimeInstanceId\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"input\":{\"workspaceId\":\"workspace-01\",\"project\":{\"projectId\":\"project-01\",\"source\":{\"kind\":\"git\",\"url\":\"https://example.test/acme/project.git\",\"revision\":\"main\",\"subdirectory\":\"packages/app\"}},\"placement\":{\"kind\":\"existing\"},\"purpose\":\"discovery\"},\"options\":{}}\n", + "lifecycleResponse": "{\"type\":\"state\",\"protocolVersion\":1,\"state\":{\"workspaceId\":\"workspace-01\",\"lifecycle\":\"ready\"},\"placement\":{\"cwd\":\"/workspace\"},\"materializedFreshContent\":true}\n", + "pipeSpawnControl": "{\"type\":\"spawn\",\"protocolVersion\":1,\"runtimeInstanceId\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"argv\":[\"/bin/sh\",\"-c\",\"printf hello\"],\"cwd\":\"packages/app\",\"env\":{\"PATH\":\"/usr/bin:/bin\"},\"purpose\":{\"kind\":\"workspace-script\"},\"options\":{},\"execId\":\"exec-pipe-01\",\"stdio\":{\"kind\":\"pipes\"}}\n", + "ptySpawnControl": "{\"type\":\"spawn\",\"protocolVersion\":1,\"runtimeInstanceId\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"argv\":[\"/bin/sh\",\"-c\",\"printf hello\"],\"cwd\":\"packages/app\",\"env\":{\"PATH\":\"/usr/bin:/bin\"},\"purpose\":{\"kind\":\"workspace-script\"},\"options\":{},\"execId\":\"exec-pty-01\",\"stdio\":{\"kind\":\"pty\",\"rows\":24,\"cols\":80,\"term\":\"xterm-256color\"}}\n", + "resizeControl": "{\"type\":\"resize\",\"protocolVersion\":1,\"id\":1,\"rows\":40,\"cols\":120}\n", + "signalControl": "{\"type\":\"signal\",\"protocolVersion\":1,\"signal\":\"SIGTERM\"}\n", + "startedEvent": "{\"type\":\"started\",\"protocolVersion\":1}\n", + "eofEvent": "{\"type\":\"eof\",\"protocolVersion\":1}\n", + "resizedEvent": "{\"type\":\"resized\",\"protocolVersion\":1,\"id\":1}\n", + "exitEvent": "{\"type\":\"exit\",\"protocolVersion\":1,\"code\":0,\"signal\":null}\n", + "errorEvent": "{\"type\":\"error\",\"protocolVersion\":1,\"message\":\"runtime control failed\"}\n" + } +} diff --git a/packages/workspace-runtime-contract/package.json b/packages/workspace-runtime-contract/package.json new file mode 100644 index 0000000000..4cc545817b --- /dev/null +++ b/packages/workspace-runtime-contract/package.json @@ -0,0 +1,34 @@ +{ + "name": "@getpaseo/workspace-runtime-contract", + "version": "0.4.0", + "description": "Versioned command workspace runtime author contract for Paseo", + "files": [ + "dist", + "examples", + "README.md" + ], + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "clean": "node ../../scripts/clean-package-dist.mjs", + "build": "tsc -p tsconfig.json --incremental false", + "build:clean": "npm run clean && npm run build", + "prepack": "npm run build:clean", + "test": "npm run build && tsc -p tsconfig.test.json && node --test test/*.test.mjs", + "typecheck": "tsgo -p tsconfig.json --noEmit" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/packages/workspace-runtime-contract/src/index.ts b/packages/workspace-runtime-contract/src/index.ts new file mode 100644 index 0000000000..41d0bb25e2 --- /dev/null +++ b/packages/workspace-runtime-contract/src/index.ts @@ -0,0 +1,351 @@ +import { z } from "zod"; + +export const COMMAND_RUNTIME_PROTOCOL_VERSION = 1 as const; + +const ProtocolVersionSchema = z.literal(COMMAND_RUNTIME_PROTOCOL_VERSION); +const EnvironmentSchema = z.record(z.string(), z.string()); +const OptionsSchema = z.record(z.string(), z.json()); +const CommandSchema = z.array(z.string()).min(1); + +export const CommandRuntimeProjectSourceSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("directory"), path: z.string() }).strict(), + z + .object({ + kind: z.literal("git"), + url: z.string(), + revision: z.string(), + subdirectory: z.string().optional(), + }) + .strict(), +]); + +const ResolvedWorktreeSourceSchema = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("branch-off"), + baseBranch: z.string(), + branchName: z.string(), + }) + .strict(), + z.object({ kind: z.literal("checkout-branch"), branchName: z.string() }).strict(), + z + .object({ + kind: z.enum(["checkout-change-request", "checkout-github-pr"]), + forge: z.string().optional(), + changeRequestNumber: z.number().optional(), + githubPrNumber: z.number().optional(), + headRef: z.string(), + headRepositoryOwner: z.string().optional(), + baseRefName: z.string(), + checkoutRefs: z + .array(z.object({ remoteName: z.string().optional(), remoteRef: z.string() }).strict()) + .optional(), + localBranchName: z.string().optional(), + pushRemoteUrl: z.string().optional(), + trackOriginHead: z.boolean().optional(), + }) + .strict(), +]); + +export const CommandRuntimePlacementIntentSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("existing"), relativeCwd: z.string().optional() }).strict(), + z + .object({ + kind: z.literal("branch"), + branchName: z.string(), + baseRef: z.string(), + relativeCwd: z.string().optional(), + worktreeSlug: z.string().optional(), + }) + .strict(), + z + .object({ + kind: z.literal("checkout"), + ref: z.string(), + relativeCwd: z.string().optional(), + worktreeSlug: z.string().optional(), + }) + .strict(), + z + .object({ + kind: z.literal("resolved-worktree"), + source: ResolvedWorktreeSourceSchema, + worktreeSlug: z.string(), + relativeCwd: z.string().optional(), + }) + .strict(), +]); + +export const CommandRuntimeCreateInputSchema = z + .object({ + workspaceId: z.string(), + project: z + .object({ projectId: z.string(), source: CommandRuntimeProjectSourceSchema }) + .strict(), + placement: CommandRuntimePlacementIntentSchema, + purpose: z.literal("discovery").optional(), + markFirstAgentBranchAutoName: z.boolean().optional(), + seedPaseoConfigFrom: z.string().optional(), + }) + .strict(); + +export const CommandRuntimeLifecycleRequestSchema = z + .object({ + protocolVersion: ProtocolVersionSchema, + runtimeInstanceId: z.string().min(1), + input: CommandRuntimeCreateInputSchema.optional(), + options: OptionsSchema, + workspaceIds: z.array(z.string()).optional(), + }) + .strict(); + +export const CommandRuntimeStateSchema = z + .object({ + workspaceId: z.string(), + lifecycle: z.enum(["ready", "paused"]), + lifecycleEnvironment: EnvironmentSchema.optional(), + }) + .strict(); + +export const CommandRuntimePlacementSchema = z + .object({ + cwd: z.string(), + }) + .strict(); + +export const CommandRuntimeInspectionSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("missing") }).strict(), + z + .object({ + status: z.literal("paused"), + state: CommandRuntimeStateSchema, + placement: CommandRuntimePlacementSchema, + }) + .strict(), + z + .object({ + status: z.literal("ready"), + state: CommandRuntimeStateSchema, + placement: CommandRuntimePlacementSchema, + }) + .strict(), + z.object({ status: z.literal("error"), message: z.string() }).strict(), +]); + +export const CommandRuntimeLifecycleResponseSchema = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("state"), + protocolVersion: ProtocolVersionSchema, + state: CommandRuntimeStateSchema, + placement: CommandRuntimePlacementSchema, + materializedFreshContent: z.boolean().optional(), + }) + .strict(), + z + .object({ + type: z.literal("inspection"), + protocolVersion: ProtocolVersionSchema, + inspection: CommandRuntimeInspectionSchema, + }) + .strict(), + z.object({ type: z.literal("ok"), protocolVersion: ProtocolVersionSchema }).strict(), +]); + +export const CommandRuntimeDescribeResponseSchema = z + .object({ + protocolVersion: ProtocolVersionSchema, + modes: z.array(z.enum(["pipes", "pty"])), + reconcile: z.boolean().optional().default(false), + }) + .strict(); + +export const CommandRuntimeProcessPurposeSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("agent") }).strict(), + z.object({ kind: z.literal("terminal") }).strict(), + z.object({ kind: z.literal("git") }).strict(), + z.object({ kind: z.literal("discovery") }).strict(), + z.object({ kind: z.literal("workspace-helper") }).strict(), + z.object({ kind: z.literal("workspace-script") }).strict(), + z.object({ kind: z.literal("setup") }).strict(), + z.object({ kind: z.literal("archive") }).strict(), +]); + +export const CommandRuntimeStdioSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("pipes") }).strict(), + z + .object({ + kind: z.literal("pty"), + rows: z.number().int().positive(), + cols: z.number().int().positive(), + term: z.string().optional(), + }) + .strict(), +]); + +export const CommandRuntimeSpawnEnvelopeSchema = z + .object({ + type: z.literal("spawn"), + protocolVersion: ProtocolVersionSchema, + runtimeInstanceId: z.string().min(1), + argv: CommandSchema, + cwd: z.string().optional(), + env: EnvironmentSchema, + purpose: CommandRuntimeProcessPurposeSchema, + options: OptionsSchema, + execId: z.string().min(1), + stdio: CommandRuntimeStdioSchema, + }) + .strict(); + +export const CommandRuntimeControlSchema = z.discriminatedUnion("type", [ + CommandRuntimeSpawnEnvelopeSchema, + z + .object({ + type: z.literal("resize"), + protocolVersion: ProtocolVersionSchema, + id: z.number().int().nonnegative(), + rows: z.number().int().positive(), + cols: z.number().int().positive(), + }) + .strict(), + z + .object({ + type: z.literal("signal"), + protocolVersion: ProtocolVersionSchema, + signal: z.string(), + }) + .strict(), +]); + +export const CommandRuntimeProcessEventSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("started"), protocolVersion: ProtocolVersionSchema }).strict(), + z.object({ type: z.literal("eof"), protocolVersion: ProtocolVersionSchema }).strict(), + z + .object({ + type: z.literal("resized"), + protocolVersion: ProtocolVersionSchema, + id: z.number().int().nonnegative(), + }) + .strict(), + z + .object({ + type: z.literal("exit"), + protocolVersion: ProtocolVersionSchema, + code: z.number().int().nullable(), + signal: z.string().nullable(), + }) + .strict(), + z + .object({ + type: z.literal("error"), + protocolVersion: ProtocolVersionSchema, + message: z.string(), + }) + .strict(), +]); + +export interface CommandRuntimeMessageSchema { + parse(value: unknown): T; +} + +export function encodeCommandRuntimeMessage( + schema: CommandRuntimeMessageSchema, + value: unknown, +): string { + return `${JSON.stringify(schema.parse(value))}\n`; +} + +export function createCommandRuntimeMessageDecoder(schema: CommandRuntimeMessageSchema): { + push(chunk: Uint8Array | string): T[]; + finish(): void; +} { + const decoder = new TextDecoder(); + let pending = ""; + return { + push(chunk) { + pending += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true }); + const messages: T[] = []; + let newline = pending.indexOf("\n"); + while (newline >= 0) { + const line = pending.slice(0, newline); + pending = pending.slice(newline + 1); + if (!line) throw new Error("Command runtime message frame is empty"); + messages.push(schema.parse(JSON.parse(line))); + newline = pending.indexOf("\n"); + } + return messages; + }, + finish() { + pending += decoder.decode(); + if (pending.length > 0) throw new Error("Command runtime message ended before newline"); + }, + }; +} + +export function createCommandRuntimeProcessEventDecoder(mode: "pipes" | "pty"): { + push(chunk: Uint8Array | string): CommandRuntimeProcessEvent[]; + finish(): void; +} { + const framing = createCommandRuntimeMessageDecoder(CommandRuntimeProcessEventSchema); + let phase: "waiting-started" | "started" | "eof" | "exit" = "waiting-started"; + + return { + push(chunk) { + const events = framing.push(chunk); + for (const event of events) advance(event); + return events; + }, + finish() { + framing.finish(); + if (phase !== "exit") { + throw new Error( + `${mode} wrapper ended without a valid fd4 exit event: incomplete started -> eof -> exit sequence`, + ); + } + }, + }; + + function advance(event: CommandRuntimeProcessEvent): void { + if (phase === "exit") { + if (event.type === "exit") throw new Error("Command runtime returned duplicate exit"); + throw new Error(`Command runtime event after exit: ${event.type}`); + } + + if (event.type === "started") { + if (phase !== "waiting-started") + throw new Error("Command runtime returned duplicate started"); + phase = "started"; + return; + } + if (event.type === "eof") { + if (phase === "waiting-started") + throw new Error("Command runtime returned eof before started"); + if (phase === "eof") throw new Error("Command runtime returned duplicate eof"); + phase = "eof"; + return; + } + if (event.type === "exit") { + if (phase === "waiting-started") + throw new Error("Command runtime returned exit before started"); + if (phase === "started") throw new Error("Command runtime returned exit before eof"); + phase = "exit"; + return; + } + if (event.type === "resized") { + if (mode === "pipes") throw new Error("pipes runtime returned resized event"); + if (phase === "waiting-started") { + throw new Error("PTY runtime returned resized before started"); + } + if (phase === "eof") throw new Error("PTY runtime returned resized after eof"); + } + } +} + +export type CommandRuntimeLifecycleRequest = z.infer; +export type CommandRuntimeLifecycleResponse = z.infer; +export type CommandRuntimeState = z.infer; +export type CommandRuntimeInspection = z.infer; +export type CommandRuntimeSpawnEnvelope = z.infer; +export type CommandRuntimeControl = z.infer; +export type CommandRuntimeProcessEvent = z.infer; diff --git a/packages/workspace-runtime-contract/test/contract.test.mjs b/packages/workspace-runtime-contract/test/contract.test.mjs new file mode 100644 index 0000000000..fb222d0d92 --- /dev/null +++ b/packages/workspace-runtime-contract/test/contract.test.mjs @@ -0,0 +1,587 @@ +import { execFile, spawn } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import test from "node:test"; +import assert from "node:assert/strict"; + +test("the public contract contains no host or provider authority fields", async () => { + const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); + assert.doesNotMatch(source, /host-directory|hostVisiblePath|provider-probe|provider:/u); +}); +import { promisify } from "node:util"; + +import { + CommandRuntimeControlSchema, + CommandRuntimeDescribeResponseSchema, + CommandRuntimeLifecycleRequestSchema, + CommandRuntimeLifecycleResponseSchema, + CommandRuntimeProcessEventSchema, + CommandRuntimeProjectSourceSchema, + CommandRuntimePlacementIntentSchema, + CommandRuntimeCreateInputSchema, + CommandRuntimeInspectionSchema, + CommandRuntimePlacementSchema, + CommandRuntimeProcessPurposeSchema, + CommandRuntimeSpawnEnvelopeSchema, + CommandRuntimeStdioSchema, + CommandRuntimeStateSchema, + createCommandRuntimeMessageDecoder, + createCommandRuntimeProcessEventDecoder, + encodeCommandRuntimeMessage, +} from "../dist/index.js"; + +const run = promisify(execFile); +const npmCommand = process.platform === "win32" ? process.execPath : "npm"; +const npmPrefixArgs = + process.platform === "win32" ? [requireEnvironmentVariable("npm_execpath")] : []; +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const examples = JSON.parse(await readFile(path.join(packageRoot, "examples/v1.json"), "utf8")); + +function requireEnvironmentVariable(name) { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required to invoke npm on Windows`); + return value; +} + +function runNpm(args, options) { + return run(npmCommand, [...npmPrefixArgs, ...args], options); +} + +test("documented lifecycle, fd3, control, and fd4 examples are exact newline-terminated bytes", () => { + for (const [name, schema] of [ + ["describeResponse", CommandRuntimeDescribeResponseSchema], + ["lifecycleRequest", CommandRuntimeLifecycleRequestSchema], + ["lifecycleResponse", CommandRuntimeLifecycleResponseSchema], + ["pipeSpawnControl", CommandRuntimeControlSchema], + ["ptySpawnControl", CommandRuntimeControlSchema], + ["resizeControl", CommandRuntimeControlSchema], + ["signalControl", CommandRuntimeControlSchema], + ["startedEvent", CommandRuntimeProcessEventSchema], + ["eofEvent", CommandRuntimeProcessEventSchema], + ["resizedEvent", CommandRuntimeProcessEventSchema], + ["exitEvent", CommandRuntimeProcessEventSchema], + ["errorEvent", CommandRuntimeProcessEventSchema], + ]) { + const bytes = encodeCommandRuntimeMessage(schema, examples[name]); + assert.equal(bytes, examples.bytes[name]); + assert.equal(bytes.endsWith("\n"), true); + assert.equal(bytes.endsWith("\n\n"), false); + } +}); + +test("lifecycle uses one exact stdin request and one exact stdout response", async () => { + assert.equal(examples.lifecycleRequest.input.purpose, "discovery"); + assert.deepEqual(examples.lifecycleResponse.state, { + workspaceId: "workspace-01", + lifecycle: "ready", + }); + assert.equal(examples.lifecycleResponse.materializedFreshContent, true); + assert.doesNotMatch(examples.bytes.lifecycleResponse, /root|revision|executionDomainId/); + const child = spawn( + process.execPath, + [path.join(packageRoot, "test/pipe-fixture.mjs"), "lifecycle"], + { + stdio: ["pipe", "pipe", "pipe"], + }, + ); + child.stdin.end(examples.bytes.lifecycleRequest); + const [stdout, stderr, exit] = await Promise.all([ + collect(child.stdout), + collect(child.stderr), + exited(child), + ]); + assert.deepEqual(exit, { code: 0, signal: null }); + assert.equal(stdout.toString(), examples.bytes.lifecycleResponse); + assert.equal(stderr.length, 0); +}); + +test("pipes use one fd3 spawn then started/eof/exit on fd4 with raw fd0/1/2", async () => { + const child = spawn(process.execPath, [path.join(packageRoot, "test/pipe-fixture.mjs")], { + stdio: ["pipe", "pipe", "pipe", "pipe", "pipe"], + }); + child.stdin.end(Buffer.from("raw\u0000stdin\n")); + child.stdio[3].end(examples.bytes.pipeSpawnControl); + const [stdout, stderr, events, exit] = await Promise.all([ + collect(child.stdout), + collect(child.stderr), + collect(child.stdio[4]), + exited(child), + ]); + assert.deepEqual(exit, { code: 0, signal: null }); + assert.deepEqual(stdout, Buffer.from([0x00, 0x6f, 0x75, 0x74, 0xff])); + assert.deepEqual(stderr, Buffer.from([0x65, 0x72, 0x72, 0x00])); + assert.equal( + events.toString(), + examples.bytes.startedEvent + examples.bytes.eofEvent + examples.bytes.exitEvent, + ); + assert.deepEqual(decodeEvents(events), [ + examples.startedEvent, + examples.eofEvent, + examples.exitEvent, + ]); +}); + +test("PTY keeps fd3 open for resize and signal through EOF and emits a valid fd4 sequence", async () => { + const child = spawn(process.execPath, [path.join(packageRoot, "test/pty-fixture.mjs")], { + stdio: ["pipe", "pipe", "pipe", "pipe", "pipe"], + }); + child.stdin.end(Buffer.from([0x74, 0x65, 0x72, 0x6d, 0x00, 0xff])); + child.stdio[3].write(examples.bytes.ptySpawnControl); + child.stdio[3].write(examples.bytes.resizeControl); + child.stdio[3].end(examples.bytes.signalControl); + const [stdout, stderr, events, exit] = await Promise.all([ + collect(child.stdout), + collect(child.stderr), + collect(child.stdio[4]), + exited(child), + ]); + assert.deepEqual(exit, { code: 0, signal: null }); + assert.deepEqual(stdout, Buffer.from([0x74, 0x65, 0x72, 0x6d, 0x00, 0xff])); + assert.deepEqual(stderr, Buffer.from([0x70, 0x74, 0x79, 0x00])); + assert.equal( + events.toString(), + examples.bytes.startedEvent + + examples.bytes.resizedEvent + + examples.bytes.eofEvent + + '{"type":"exit","protocolVersion":1,"code":null,"signal":"SIGTERM"}\n', + ); + assert.deepEqual( + decodeEvents(events).map((event) => event.type), + ["started", "resized", "eof", "exit"], + ); +}); + +test("actual fd4 rejects malformed and wrong-version frames and detects early wrapper exit", async () => { + for (const [mode, pattern] of [ + ["malformed", SyntaxError], + ["wrong-version", /expected 1/], + ]) { + const child = spawn(process.execPath, [path.join(packageRoot, "test/pipe-fixture.mjs"), mode], { + stdio: ["ignore", "ignore", "ignore", "ignore", "pipe"], + }); + const [events, exit] = await Promise.all([collect(child.stdio[4]), exited(child)]); + assert.deepEqual(exit, { code: 0, signal: null }); + assert.throws(() => decodeEvents(events), pattern); + } + + const early = spawn( + process.execPath, + [path.join(packageRoot, "test/pipe-fixture.mjs"), "early-exit"], + { stdio: ["ignore", "ignore", "ignore", "ignore", "pipe"] }, + ); + const [events, exit] = await Promise.all([collect(early.stdio[4]), exited(early)]); + assert.equal(events.length, 0); + assert.deepEqual(exit, { code: 47, signal: null }); +}); + +test("framing accepts partial bytes and rejects malformed, early EOF, and wrong versions", () => { + const partial = createCommandRuntimeMessageDecoder(CommandRuntimeProcessEventSchema); + assert.deepEqual(partial.push(examples.bytes.exitEvent.slice(0, 11)), []); + assert.deepEqual(partial.push(examples.bytes.exitEvent.slice(11)), [examples.exitEvent]); + partial.finish(); + + const malformed = createCommandRuntimeMessageDecoder(CommandRuntimeProcessEventSchema); + assert.throws(() => malformed.push("{nope}\n"), SyntaxError); + + const early = createCommandRuntimeMessageDecoder(CommandRuntimeProcessEventSchema); + early.push(examples.bytes.exitEvent.slice(0, -1)); + assert.throws(() => early.finish(), /ended before newline/); + + const wrongVersion = createCommandRuntimeMessageDecoder(CommandRuntimeProcessEventSchema); + assert.throws( + () => wrongVersion.push('{"type":"exit","protocolVersion":2,"code":0,"signal":null}\n'), + /expected 1/, + ); +}); + +test("process event conversations enforce the complete mode-specific lifecycle", () => { + for (const [mode, events] of [ + ["pipes", [examples.startedEvent, examples.eofEvent, examples.exitEvent]], + ["pty", [examples.startedEvent, examples.resizedEvent, examples.eofEvent, examples.exitEvent]], + ]) { + const decoder = createCommandRuntimeProcessEventDecoder(mode); + assert.deepEqual( + decoder.push( + events + .map((event) => encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, event)) + .join(""), + ), + events, + ); + decoder.finish(); + } + + for (const [name, mode, events, pattern] of [ + ["exit before eof", "pipes", [examples.startedEvent, examples.exitEvent], /exit before eof/], + ["eof before started", "pipes", [examples.eofEvent], /eof before started/], + ["exit before started", "pipes", [examples.exitEvent], /exit before started/], + [ + "duplicate started", + "pipes", + [examples.startedEvent, examples.startedEvent], + /duplicate started/, + ], + [ + "duplicate eof", + "pipes", + [examples.startedEvent, examples.eofEvent, examples.eofEvent], + /duplicate eof/, + ], + [ + "duplicate exit", + "pipes", + [examples.startedEvent, examples.eofEvent, examples.exitEvent, examples.exitEvent], + /duplicate exit/, + ], + [ + "post-exit lifecycle", + "pipes", + [examples.startedEvent, examples.eofEvent, examples.exitEvent, examples.eofEvent], + /event after exit/, + ], + [ + "wrong-mode resize", + "pipes", + [examples.startedEvent, examples.resizedEvent], + /pipes.*resized/, + ], + ["PTY resize before started", "pty", [examples.resizedEvent], /resized before started/], + [ + "PTY resize after eof", + "pty", + [examples.startedEvent, examples.eofEvent, examples.resizedEvent], + /resized after eof/, + ], + [ + "PTY post-exit acknowledgement", + "pty", + [examples.startedEvent, examples.eofEvent, examples.exitEvent, examples.resizedEvent], + /event after exit/, + ], + ]) { + const decoder = createCommandRuntimeProcessEventDecoder(mode); + assert.throws( + () => + decoder.push( + events + .map((event) => encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, event)) + .join(""), + ), + pattern, + name, + ); + } +}); + +test("every public v1 object rejects unknown authority at every nesting level", () => { + const createInput = examples.lifecycleRequest.input; + const gitSource = { + kind: "git", + url: "https://example.invalid/repository.git", + revision: "main", + subdirectory: "project", + }; + const resolvedPlacement = { + kind: "resolved-worktree", + worktreeSlug: "change-7", + source: { + kind: "checkout-change-request", + headRef: "feature", + baseRefName: "main", + checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/heads/feature" }], + }, + }; + const readyInspection = { + status: "ready", + state: examples.lifecycleResponse.state, + placement: examples.lifecycleResponse.placement, + }; + const pausedInspection = { + status: "paused", + state: { ...examples.lifecycleResponse.state, lifecycle: "paused" }, + placement: examples.lifecycleResponse.placement, + }; + const strictCases = [ + ["describe", CommandRuntimeDescribeResponseSchema, examples.describeResponse, []], + ["create request", CommandRuntimeLifecycleRequestSchema, examples.lifecycleRequest, []], + [ + "create request input", + CommandRuntimeLifecycleRequestSchema, + examples.lifecycleRequest, + ["input"], + ], + [ + "inspect request", + CommandRuntimeLifecycleRequestSchema, + { protocolVersion: 1, options: {} }, + [], + ], + [ + "pause request", + CommandRuntimeLifecycleRequestSchema, + { protocolVersion: 1, options: {} }, + [], + ], + [ + "resume request", + CommandRuntimeLifecycleRequestSchema, + { protocolVersion: 1, options: {} }, + [], + ], + [ + "destroy request", + CommandRuntimeLifecycleRequestSchema, + { protocolVersion: 1, options: {} }, + [], + ], + ["create input", CommandRuntimeCreateInputSchema, createInput, []], + ["create project", CommandRuntimeCreateInputSchema, createInput, ["project"]], + ["create source", CommandRuntimeCreateInputSchema, createInput, ["project", "source"]], + ["create placement", CommandRuntimeCreateInputSchema, createInput, ["placement"]], + ["host source", CommandRuntimeProjectSourceSchema, createInput.project.source, []], + ["git source", CommandRuntimeProjectSourceSchema, gitSource, []], + ["existing placement", CommandRuntimePlacementIntentSchema, createInput.placement, []], + [ + "branch placement", + CommandRuntimePlacementIntentSchema, + { kind: "branch", branchName: "feature", baseRef: "main" }, + [], + ], + [ + "checkout placement", + CommandRuntimePlacementIntentSchema, + { kind: "checkout", ref: "feature" }, + [], + ], + ["resolved placement", CommandRuntimePlacementIntentSchema, resolvedPlacement, []], + ["resolved source", CommandRuntimePlacementIntentSchema, resolvedPlacement, ["source"]], + [ + "resolved checkout ref", + CommandRuntimePlacementIntentSchema, + resolvedPlacement, + ["source", "checkoutRefs", 0], + ], + ["state", CommandRuntimeStateSchema, examples.lifecycleResponse.state, []], + ["placement", CommandRuntimePlacementSchema, examples.lifecycleResponse.placement, []], + [ + "create state response", + CommandRuntimeLifecycleResponseSchema, + examples.lifecycleResponse, + [], + ], + [ + "resume state response", + CommandRuntimeLifecycleResponseSchema, + examples.lifecycleResponse, + [], + ], + [ + "state response state", + CommandRuntimeLifecycleResponseSchema, + examples.lifecycleResponse, + ["state"], + ], + [ + "state response placement", + CommandRuntimeLifecycleResponseSchema, + examples.lifecycleResponse, + ["placement"], + ], + ["ready inspection", CommandRuntimeInspectionSchema, readyInspection, []], + ["ready inspection state", CommandRuntimeInspectionSchema, readyInspection, ["state"]], + ["ready inspection placement", CommandRuntimeInspectionSchema, readyInspection, ["placement"]], + ["paused inspection", CommandRuntimeInspectionSchema, pausedInspection, []], + ["paused inspection state", CommandRuntimeInspectionSchema, pausedInspection, ["state"]], + [ + "paused inspection placement", + CommandRuntimeInspectionSchema, + pausedInspection, + ["placement"], + ], + ["missing inspection", CommandRuntimeInspectionSchema, { status: "missing" }, []], + [ + "error inspection", + CommandRuntimeInspectionSchema, + { status: "error", message: "failure" }, + [], + ], + [ + "inspection response", + CommandRuntimeLifecycleResponseSchema, + { protocolVersion: 1, type: "inspection", inspection: { status: "missing" } }, + [], + ], + [ + "ready inspection response state", + CommandRuntimeLifecycleResponseSchema, + { protocolVersion: 1, type: "inspection", inspection: readyInspection }, + ["inspection", "state"], + ], + [ + "ready inspection response placement", + CommandRuntimeLifecycleResponseSchema, + { protocolVersion: 1, type: "inspection", inspection: readyInspection }, + ["inspection", "placement"], + ], + [ + "nested inspection", + CommandRuntimeLifecycleResponseSchema, + { protocolVersion: 1, type: "inspection", inspection: { status: "missing" } }, + ["inspection"], + ], + [ + "pause ok response", + CommandRuntimeLifecycleResponseSchema, + { protocolVersion: 1, type: "ok" }, + [], + ], + [ + "destroy ok response", + CommandRuntimeLifecycleResponseSchema, + { protocolVersion: 1, type: "ok" }, + [], + ], + ["agent purpose", CommandRuntimeProcessPurposeSchema, { kind: "agent" }, []], + ["terminal purpose", CommandRuntimeProcessPurposeSchema, { kind: "terminal" }, []], + ["Git purpose", CommandRuntimeProcessPurposeSchema, { kind: "git" }, []], + ["discovery purpose", CommandRuntimeProcessPurposeSchema, { kind: "discovery" }, []], + ["helper purpose", CommandRuntimeProcessPurposeSchema, { kind: "workspace-helper" }, []], + ["script purpose", CommandRuntimeProcessPurposeSchema, examples.pipeSpawnControl.purpose, []], + ["setup purpose", CommandRuntimeProcessPurposeSchema, { kind: "setup" }, []], + ["archive purpose", CommandRuntimeProcessPurposeSchema, { kind: "archive" }, []], + ["pipes stdio", CommandRuntimeStdioSchema, { kind: "pipes" }, []], + ["PTY stdio", CommandRuntimeStdioSchema, examples.ptySpawnControl.stdio, []], + ["spawn", CommandRuntimeSpawnEnvelopeSchema, examples.pipeSpawnControl, []], + ["spawn purpose", CommandRuntimeSpawnEnvelopeSchema, examples.pipeSpawnControl, ["purpose"]], + ["spawn stdio", CommandRuntimeSpawnEnvelopeSchema, examples.pipeSpawnControl, ["stdio"]], + ["resize control", CommandRuntimeControlSchema, examples.resizeControl, []], + ["signal control", CommandRuntimeControlSchema, examples.signalControl, []], + ["started event", CommandRuntimeProcessEventSchema, examples.startedEvent, []], + ["eof event", CommandRuntimeProcessEventSchema, examples.eofEvent, []], + ["resized event", CommandRuntimeProcessEventSchema, examples.resizedEvent, []], + ["exit event", CommandRuntimeProcessEventSchema, examples.exitEvent, []], + ["error event", CommandRuntimeProcessEventSchema, examples.errorEvent, []], + ]; + for (const [name, schema, value, nestingPath] of strictCases) { + for (const field of ["root", "unknownV1Field"]) { + assert.throws( + () => schema.parse(inject(value, nestingPath, field, "/private")), + /unrecognized key/i, + `${name} accepted ${field}`, + ); + } + } +}); + +test("v1 extension maps stay explicit and future versions fail by protocol version", () => { + assert.deepEqual( + CommandRuntimeLifecycleRequestSchema.parse({ + protocolVersion: 1, + runtimeInstanceId: examples.lifecycleRequest.runtimeInstanceId, + options: { root: "runtime-private-option", vendorExtension: { nested: true } }, + }).options, + { root: "runtime-private-option", vendorExtension: { nested: true } }, + ); + assert.deepEqual( + CommandRuntimeStateSchema.parse({ + ...examples.lifecycleResponse.state, + lifecycleEnvironment: { root: "environment-value", VENDOR_VALUE: "accepted" }, + }).lifecycleEnvironment, + { root: "environment-value", VENDOR_VALUE: "accepted" }, + ); + assert.deepEqual( + CommandRuntimeSpawnEnvelopeSchema.parse({ + ...examples.pipeSpawnControl, + env: { root: "workload-environment-value" }, + options: { vendorExtension: true }, + }), + { + ...examples.pipeSpawnControl, + env: { root: "workload-environment-value" }, + options: { vendorExtension: true }, + }, + ); + for (const [schema, value] of [ + [CommandRuntimeDescribeResponseSchema, examples.describeResponse], + [CommandRuntimeLifecycleRequestSchema, examples.lifecycleRequest], + [CommandRuntimeLifecycleResponseSchema, examples.lifecycleResponse], + [CommandRuntimeControlSchema, examples.pipeSpawnControl], + [CommandRuntimeProcessEventSchema, examples.startedEvent], + ]) { + assert.throws(() => schema.parse({ ...value, protocolVersion: 2 }), /expected 1/); + } +}); + +test("public state schemas reject physical authority under computed and aliased spellings", () => { + const root = ["ro", "ot"].join(""); + const state = { workspaceId: "workspace-01", lifecycle: "ready", [root]: "/private" }; + const schemaAlias = CommandRuntimeStateSchema; + assert.throws(() => schemaAlias.parse(state), /unrecognized key/i); + assert.throws( + () => + CommandRuntimeLifecycleResponseSchema.parse({ + ...examples.lifecycleResponse, + state: { ...examples.lifecycleResponse.state, [root]: "/private" }, + }), + /unrecognized key/i, + ); +}); + +test("every export condition resolves from a packed standalone install", async (t) => { + const root = await mkdtemp(path.join(tmpdir(), "workspace-runtime-contract-pack-")); + t.after(() => rm(root, { recursive: true, force: true })); + const pack = JSON.parse( + ( + await runNpm(["pack", "--json", "--ignore-scripts", "--pack-destination", root], { + cwd: packageRoot, + }) + ).stdout, + ); + const archive = path.join(root, pack[0].filename); + const installRoot = path.join(root, "standalone"); + await mkdir(installRoot); + await runNpm(["install", "--ignore-scripts", "--no-package-lock", archive], { + cwd: installRoot, + }); + const installed = path.join(installRoot, "node_modules/@getpaseo/workspace-runtime-contract"); + const manifest = JSON.parse(await readFile(path.join(installed, "package.json"), "utf8")); + for (const target of Object.values(manifest.exports["."])) { + await stat(path.join(installed, target)); + } + const imported = await import(pathToFileURL(path.join(installed, manifest.exports["."].default))); + assert.equal(imported.COMMAND_RUNTIME_PROTOCOL_VERSION, 1); +}); + +async function collect(stream) { + const chunks = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks); +} + +function exited(child) { + return new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + }); +} + +function decodeEvents(bytes) { + const decoder = createCommandRuntimeMessageDecoder(CommandRuntimeProcessEventSchema); + const split = Math.min(13, bytes.length); + const events = [ + ...decoder.push(bytes.subarray(0, split)), + ...decoder.push(bytes.subarray(split)), + ]; + decoder.finish(); + return events; +} + +function inject(value, nestingPath, field, injected) { + const copy = structuredClone(value); + let target = copy; + for (const segment of nestingPath) target = target[segment]; + target[field] = injected; + return copy; +} diff --git a/packages/workspace-runtime-contract/test/pipe-fixture.mjs b/packages/workspace-runtime-contract/test/pipe-fixture.mjs new file mode 100644 index 0000000000..0c9fd1179e --- /dev/null +++ b/packages/workspace-runtime-contract/test/pipe-fixture.mjs @@ -0,0 +1,56 @@ +import { readFileSync, writeSync } from "node:fs"; + +import { + CommandRuntimeLifecycleRequestSchema, + CommandRuntimeLifecycleResponseSchema, + CommandRuntimeProcessEventSchema, + CommandRuntimeSpawnEnvelopeSchema, + encodeCommandRuntimeMessage, +} from "../dist/index.js"; + +const examples = JSON.parse(readFileSync(new URL("../examples/v1.json", import.meta.url), "utf8")); +const mode = process.argv[2] ?? "conversation"; + +if (mode === "lifecycle") { + const input = await read(process.stdin); + CommandRuntimeLifecycleRequestSchema.parse(JSON.parse(input)); + process.stdout.write( + encodeCommandRuntimeMessage(CommandRuntimeLifecycleResponseSchema, examples.lifecycleResponse), + ); +} else if (mode === "early-exit") { + process.exitCode = 47; +} else if (mode === "malformed") { + writeSync(4, "{malformed}\n"); +} else if (mode === "wrong-version") { + writeSync(4, '{"type":"started","protocolVersion":2}\n'); +} else { + const fd3 = readFileSync(3, "utf8"); + const envelope = CommandRuntimeSpawnEnvelopeSchema.parse(JSON.parse(fd3)); + if (envelope.stdio.kind !== "pipes") throw new Error("Expected a pipes spawn envelope"); + const stdin = await read(process.stdin); + process.stdout.write(Buffer.from([0x00, 0x6f, 0x75, 0x74, 0xff])); + process.stderr.write(Buffer.from([0x65, 0x72, 0x72, 0x00])); + await writeEvent(examples.startedEvent, true); + await writeEvent(examples.eofEvent); + await writeEvent(examples.exitEvent); + if (fd3 !== examples.bytes.pipeSpawnControl || stdin !== "raw\u0000stdin\n") { + process.exitCode = 9; + } +} + +async function writeEvent(event, partial = false) { + const bytes = encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, event); + if (!partial) { + writeSync(4, bytes); + return; + } + writeSync(4, bytes.slice(0, 13)); + await new Promise((resolve) => setImmediate(resolve)); + writeSync(4, bytes.slice(13)); +} + +async function read(stream) { + const chunks = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/packages/workspace-runtime-contract/test/pty-fixture.mjs b/packages/workspace-runtime-contract/test/pty-fixture.mjs new file mode 100644 index 0000000000..dcbd6ccbdb --- /dev/null +++ b/packages/workspace-runtime-contract/test/pty-fixture.mjs @@ -0,0 +1,42 @@ +import { createInterface } from "node:readline"; +import { Socket } from "node:net"; + +import { + CommandRuntimeControlSchema, + CommandRuntimeProcessEventSchema, + encodeCommandRuntimeMessage, +} from "../dist/index.js"; + +const control = new Socket({ fd: 3, readable: true, writable: false }); +const events = new Socket({ fd: 4, readable: false, writable: true }); +const lines = createInterface({ input: control, crlfDelay: Infinity }); +const received = []; + +process.stdin.on("data", (chunk) => process.stdout.write(chunk)); +process.stderr.write(Buffer.from([0x70, 0x74, 0x79, 0x00])); + +for await (const line of lines) { + const message = CommandRuntimeControlSchema.parse(JSON.parse(line)); + received.push(message); + if (received.length === 1) { + if (message.type !== "spawn" || message.stdio.kind !== "pty") { + throw new Error("Expected a PTY spawn envelope"); + } + writeEvent({ type: "started", protocolVersion: 1 }); + } else if (message.type === "resize") { + writeEvent({ type: "resized", protocolVersion: 1, id: message.id }); + } else if (message.type !== "signal") { + throw new Error(`Unexpected PTY control: ${message.type}`); + } +} + +if (received.map((message) => message.type).join(",") !== "spawn,resize,signal") { + throw new Error("Expected spawn, resize, signal, then fd3 EOF"); +} +writeEvent({ type: "eof", protocolVersion: 1 }); +writeEvent({ type: "exit", protocolVersion: 1, code: null, signal: "SIGTERM" }); +events.end(); + +function writeEvent(event) { + events.write(encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, event)); +} diff --git a/packages/workspace-runtime-contract/test/public-types.ts b/packages/workspace-runtime-contract/test/public-types.ts new file mode 100644 index 0000000000..f23f5ced2f --- /dev/null +++ b/packages/workspace-runtime-contract/test/public-types.ts @@ -0,0 +1,26 @@ +import type { CommandRuntimeState } from "../src/index.js"; + +interface ExpectedState { + workspaceId: string; + lifecycle: "ready" | "paused"; + lifecycleEnvironment?: Record; +} + +type Equal = + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 + ? true + : false; +const exactStateShape: Equal = true; +void exactStateShape; + +declare const state: CommandRuntimeState; +declare const inspection: { state: CommandRuntimeState }; +const rootKey = ["ro", "ot"].join(""); +// @ts-expect-error Physical placement is not part of public runtime state. +void state.root; +// @ts-expect-error Constant aliases cannot recover private placement. +void state["root" as const]; +// @ts-expect-error Computed string access has no public state index signature. +void state[rootKey]; +// @ts-expect-error Chained bracket access cannot cross the public state boundary. +void inspection["state"]["root"]; diff --git a/packages/workspace-runtime-contract/tsconfig.json b/packages/workspace-runtime-contract/tsconfig.json new file mode 100644 index 0000000000..9e5d1f3e76 --- /dev/null +++ b/packages/workspace-runtime-contract/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "lib": ["ES2022", "DOM"], + "declaration": true, + "incremental": false, + "noEmit": false + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/workspace-runtime-contract/tsconfig.test.json b/packages/workspace-runtime-contract/tsconfig.test.json new file mode 100644 index 0000000000..d28701f647 --- /dev/null +++ b/packages/workspace-runtime-contract/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*.ts", "test/public-types.ts"] +} diff --git a/public-docs/configuration.md b/public-docs/configuration.md index c4189f7dd0..b98190c8ff 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -91,6 +91,26 @@ New worktrees are created under `$PASEO_HOME/worktrees` by default. To place new Relative paths are resolved against `PASEO_HOME`. Existing worktrees remain where they are; changing this setting only changes where Paseo creates and discovers Paseo-managed worktrees going forward. +## Workspace runtimes + +New Workspace always offers Local and Worktree. Other runtimes appear only when you register an +external command runtime in daemon configuration: + +```json +{ + "workspaceRuntimes": { + "isolated": { + "type": "command", + "label": "Isolated", + "command": ["/absolute/path/to/runtime-executable"], + "options": {} + } + } +} +``` + +Every registered runtime uses `type: "command"`. `command` is an argv array, not a shell string. Its first value may be a package executable, filesystem path, or executable on `PATH`. Optional `label` names the runtime in New Workspace, and `options` passes arbitrary JSON to the runtime unchanged. Remove an entry to unregister it. Every runtime must provide `paseo-workspace-helper` inside its execution environment. Runtime implementations follow the [`@getpaseo/workspace-runtime-contract`](https://github.com/getpaseo/paseo/tree/main/packages/workspace-runtime-contract). + ## Voice Voice is configured through `features.dictation` and `features.voiceMode`, with provider credentials under `providers`. diff --git a/public-docs/workspaces.md b/public-docs/workspaces.md index aa810c9bd6..dfd4815484 100644 --- a/public-docs/workspaces.md +++ b/public-docs/workspaces.md @@ -35,14 +35,15 @@ That matters because real development rarely fits into one long chat. You might In Paseo, the workspace is the stable container. The sessions are what you run inside it. -## Choose the isolation +## Choose the runtime -Every workspace has an isolation mode: +Every workspace has a runtime: - **Local** uses an existing directory, such as your main checkout. Use it when sessions should share the files already on disk. - **Worktree** creates or opens a managed git worktree. Use it when a task needs its own directory and branch. +- **Docker** materializes the project's committed Git content in a container-owned workspace. Use it when files, tools, and providers should run in the configured image instead of the host environment. -The workspace is the product concept; a git worktree is one way to isolate its files. More than one workspace can refer to the same managed worktree, and Paseo removes that worktree after its last workspace is archived. +The workspace is the product concept; the runtime chooses where its files and commands live. More than one workspace can refer to the same managed worktree, and Paseo removes that worktree after its last workspace is archived. ## Creating a workspace diff --git a/runtimes/fixture/package.json b/runtimes/fixture/package.json new file mode 100644 index 0000000000..28de98c844 --- /dev/null +++ b/runtimes/fixture/package.json @@ -0,0 +1,23 @@ +{ + "name": "@getpaseo/fixture-workspace-runtime", + "version": "0.4.0", + "private": true, + "description": "External command-runtime contract fixture", + "bin": { + "paseo-fixture-workspace-runtime": "./src/index.mjs" + }, + "files": [ + "src", + "dist" + ], + "type": "module", + "scripts": { + "build": "npm --prefix ../.. run build:workspace-runtime-contract && npm --prefix ../.. run build:workspace-helper && node --check src/index.mjs && node -e \"import('@getpaseo/workspace-helper').then(({workspaceHelperExecutable})=>{const fs=require('node:fs');fs.mkdirSync('dist',{recursive:true});fs.copyFileSync(workspaceHelperExecutable,'dist/paseo-workspace-helper');fs.chmodSync('dist/paseo-workspace-helper',0o755)})\"", + "typecheck": "node --check src/index.mjs" + }, + "dependencies": { + "@getpaseo/workspace-helper": "0.4.0", + "@getpaseo/workspace-runtime-contract": "0.4.0", + "node-pty": "1.2.0-beta.15" + } +} diff --git a/runtimes/fixture/src/index.mjs b/runtimes/fixture/src/index.mjs new file mode 100755 index 0000000000..0a4c3e5574 --- /dev/null +++ b/runtimes/fixture/src/index.mjs @@ -0,0 +1,588 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { watch } from "node:fs"; +import { chmod, cp, copyFile, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Socket } from "node:net"; +import { createInterface } from "node:readline"; +import * as pty from "node-pty"; + +const workspaceHelper = fileURLToPath(new URL("../dist/paseo-workspace-helper", import.meta.url)); +import { + COMMAND_RUNTIME_PROTOCOL_VERSION, + CommandRuntimeControlSchema, + CommandRuntimeDescribeResponseSchema, + CommandRuntimeLifecycleRequestSchema, + CommandRuntimeLifecycleResponseSchema, + CommandRuntimeProcessEventSchema, + encodeCommandRuntimeMessage, +} from "@getpaseo/workspace-runtime-contract"; + +const operation = process.argv + .slice(2) + .find((value) => + ["describe", "create", "inspect", "exec", "signal", "pause", "resume", "destroy"].includes( + value, + ), + ); +const workspaceId = argument("--workspace-id"); +let pipeChild = null; +let forwardedPipeSignal = null; +const pipeSignalHandlers = new Map(); +if (operation === "exec") { + for (const signalName of ["SIGINT", "SIGTERM", "SIGHUP"]) { + const handler = () => { + forwardedPipeSignal = signalName; + if (pipeChild) killGroup(pipeChild.pid, signalName); + }; + pipeSignalHandlers.set(signalName, handler); + process.on(signalName, handler); + } +} + +try { + if (operation === "describe") { + const protocolVersion = Number( + argument("--protocol-version") ?? COMMAND_RUNTIME_PROTOCOL_VERSION, + ); + const response = { + protocolVersion, + modes: argument("--modes") === "pipes" ? ["pipes"] : ["pipes", "pty"], + }; + if (protocolVersion === COMMAND_RUNTIME_PROTOCOL_VERSION) { + writeJson(CommandRuntimeDescribeResponseSchema, response); + } else { + process.stdout.write(`${JSON.stringify(response)}\n`); + } + } else if (!workspaceId) { + throw new Error("--workspace-id is required"); + } else if (operation === "exec") { + await execute(workspaceId); + } else if (operation === "signal") { + await signal(workspaceId, requireArgument("--exec-id"), requireArgument("--signal")); + } else { + const request = CommandRuntimeLifecycleRequestSchema.parse( + JSON.parse(await readStream(process.stdin)), + ); + switch (operation) { + case "create": + { + const result = await create(workspaceId, request); + writeJson(CommandRuntimeLifecycleResponseSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "state", + state: publicState(result.state), + placement: publicPlacement(result.state), + materializedFreshContent: result.materializedFreshContent, + }); + } + break; + case "inspect": + writeJson(CommandRuntimeLifecycleResponseSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "inspection", + inspection: await inspect(workspaceId, request.options), + }); + break; + case "pause": + await setLifecycle(workspaceId, request.options, "paused"); + writeJson(CommandRuntimeLifecycleResponseSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "ok", + }); + break; + case "resume": + { + const state = await setLifecycle(workspaceId, request.options, "ready"); + writeJson(CommandRuntimeLifecycleResponseSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "state", + state: publicState(state), + placement: publicPlacement(state), + }); + } + break; + case "destroy": + await destroy(workspaceId, request.options); + writeJson(CommandRuntimeLifecycleResponseSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "ok", + }); + break; + default: + throw new Error(`Unknown operation: ${operation}`); + } + } +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} + +async function create(id, request) { + const existing = await readState(id, request.options); + if (existing) return { state: existing, materializedFreshContent: false }; + if (request.options.failCreate === true) { + throw new Error(String(request.options.createError ?? "Fixture workspace creation failed")); + } + const sourceRoot = request.input.project.source.path; + const ownedRoot = request.options.materializeRoot + ? path.join(request.options.materializeRoot, id) + : null; + const root = ownedRoot ?? sourceRoot; + if (ownedRoot) { + await rm(ownedRoot, { recursive: true, force: true }); + await mkdir(path.dirname(ownedRoot), { recursive: true }); + await cp(sourceRoot, ownedRoot, { recursive: true }); + } + if (request.options.fixtureProviderSource) { + const providerPath = path.join(root, ".paseo-fixture-agent.mjs"); + await copyFile(request.options.fixtureProviderSource, providerPath); + await chmod(providerPath, 0o755); + } + const state = { + workspaceId: id, + root, + displayCwd: request.options.preserveSourceDisplayCwd + ? sourceRoot + : (request.options.displayCwd ?? root), + lifecycle: "ready", + lifecycleEnvironment: request.options.lifecycleEnvironment, + ownedRoot, + createInput: request.input, + }; + await mkdir(request.options.stateDirectory, { recursive: true }); + await writeFile(stateFile(id, request.options), JSON.stringify(state)); + return { state, materializedFreshContent: true }; +} + +async function destroy(id, options) { + const state = await readState(id, options); + if (state?.ownedRoot) await rm(state.ownedRoot, { recursive: true, force: true }); + await rm(stateFile(id, options), { force: true }); +} + +async function inspect(id, options) { + await applyInspectBarrier(options); + const state = await readState(id, options); + return state + ? { status: state.lifecycle, state: publicState(state), placement: publicPlacement(state) } + : { status: "missing" }; +} + +function publicState(state) { + return { + workspaceId: state.workspaceId, + lifecycle: state.lifecycle, + ...(state.lifecycleEnvironment ? { lifecycleEnvironment: state.lifecycleEnvironment } : {}), + }; +} + +function publicPlacement(state) { + return { cwd: state.displayCwd }; +} + +async function setLifecycle(id, options, lifecycle) { + const state = await readState(id, options); + if (!state) throw new Error(`Fixture workspace is missing: ${id}`); + const updated = { ...state, lifecycle }; + await writeFile(stateFile(id, options), JSON.stringify(updated)); + return updated; +} + +async function execute(id) { + const control = new Socket({ fd: 3, readable: true, writable: false }); + const lines = createInterface({ input: control, crlfDelay: Infinity }); + const iterator = lines[Symbol.asyncIterator](); + const first = await iterator.next(); + if (first.done) throw new Error("spawn control is required"); + const envelope = CommandRuntimeControlSchema.parse(JSON.parse(first.value)); + if (envelope.type !== "spawn") throw new Error("spawn control is required"); + const state = await readState(id, envelope.options); + if (!state) throw new Error(`Fixture workspace is missing: ${id}`); + if (envelope.options.recordLaunchInWorkspace !== false) { + await writeFile( + path.join(state.root, ".runtime-launch.json"), + JSON.stringify({ argv: process.argv, purpose: envelope.purpose }), + ); + } + if (envelope.stdio.kind === "pty") { + for (const [signalName, handler] of pipeSignalHandlers) process.off(signalName, handler); + await executePty(id, envelope, iterator, control); + control.destroy(); + return; + } + const events = new Socket({ fd: 4, readable: false, writable: true }); + const argv = resolveFixtureCommand(envelope); + pipeChild = spawn(argv[0], argv.slice(1), { + cwd: await resolveCwd(state.root, envelope.cwd), + env: envelope.env, + detached: true, + stdio: ["inherit", "inherit", "inherit"], + }); + const exitPromise = new Promise((resolve, reject) => { + pipeChild.once("error", reject); + pipeChild.once("exit", (code, signalName) => resolve({ code, signal: signalName })); + }); + await writeFile(execFile(id, envelope.execId, envelope.options), String(pipeChild.pid)); + if ( + envelope.options.recordWorkloadPidAt && + envelope.options.processEventPurposeKind === envelope.purpose.kind + ) { + await writeFile(envelope.options.recordWorkloadPidAt, String(pipeChild.pid)); + } + if (await emitConfiguredProcessEvents(events, envelope.options, envelope.purpose)) { + await exitPromise; + await rm(execFile(id, envelope.execId, envelope.options), { force: true }); + return; + } + events.write( + encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "started", + }), + ); + if (forwardedPipeSignal) killGroup(pipeChild.pid, forwardedPipeSignal); + if ( + envelope.options.crashPipeWrapper && + envelope.purpose.kind === "workspace-script" && + (await consumePipeWrapperCrash(id, envelope.options)) + ) { + process.exit(47); + } + const exit = await exitPromise; + await rm(execFile(id, envelope.execId, envelope.options), { force: true }); + for (const [signalName, handler] of pipeSignalHandlers) process.off(signalName, handler); + const signalName = exit.signal ?? forwardedPipeSignal; + events.write( + encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "eof", + }), + ); + await new Promise((resolve, reject) => + events + .end( + encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "exit", + code: signalName ? null : exit.code, + signal: signalName, + }), + resolve, + ) + .once("error", reject), + ); + if (signalName) { + process.removeAllListeners(signalName); + process.kill(process.pid, signalName); + } else { + process.exitCode = exit.code ?? 1; + } +} + +function resolveFixtureCommand(envelope) { + if (envelope.purpose.kind !== "workspace-helper") return envelope.argv; + const configured = envelope.options.fixtureHelperCommand; + if (Array.isArray(configured) && configured.every((value) => typeof value === "string")) { + return [...configured, ...envelope.argv.slice(1)]; + } + return [process.execPath, workspaceHelper, ...envelope.argv.slice(1)]; +} + +async function executePty(id, envelope, controls, controlStream) { + const events = new Socket({ fd: 4, readable: false, writable: true }); + const child = pty.spawn(envelope.argv[0], envelope.argv.slice(1), { + cwd: await resolveCwd((await readState(id, envelope.options)).root, envelope.cwd), + env: envelope.env, + name: envelope.stdio.term ?? "xterm-256color", + cols: envelope.stdio.cols, + rows: envelope.stdio.rows, + }); + await writeFile(execFile(id, envelope.execId, envelope.options), String(child.pid)); + if ( + envelope.options.recordWorkloadPidAt && + envelope.options.processEventPurposeKind === envelope.purpose.kind + ) { + await writeFile(envelope.options.recordWorkloadPidAt, String(child.pid)); + } + if (await emitConfiguredProcessEvents(events, envelope.options, envelope.purpose)) { + await new Promise((resolve) => child.onExit(resolve)); + await rm(execFile(id, envelope.execId, envelope.options), { force: true }); + return; + } + events.write( + encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "started", + }), + ); + if (envelope.options.closePtyControl) setTimeout(() => controlStream.destroy(), 100); + process.stdin.on("data", (data) => child.write(data.toString())); + child.onData((data) => process.stdout.write(data)); + if (envelope.options.invalidPtyEvent) { + setTimeout( + () => + events.end( + `${JSON.stringify({ protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "invalid" })}\n`, + ), + 100, + ); + } + let requestedSignal = null; + void (async () => { + while (true) { + const next = await controls.next(); + if (next.done) return; + const control = CommandRuntimeControlSchema.parse(JSON.parse(next.value)); + if (control.type === "resize") { + child.resize(control.cols, control.rows); + events.write( + `${JSON.stringify({ protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "resized", id: control.id })}\n`, + ); + } else if (control.type === "signal") { + requestedSignal = control.signal; + child.kill(control.signal); + } else throw new Error(`Unexpected PTY control: ${control.type}`); + } + })().catch((error) => { + events.write( + `${JSON.stringify({ protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "error", message: error.message })}\n`, + ); + child.kill("SIGKILL"); + }); + const exit = await new Promise((resolve) => + child.onExit(({ exitCode, signal: signalNumber }) => + resolve({ exitCode, signal: signalNumber }), + ), + ); + await rm(execFile(id, envelope.execId, envelope.options), { force: true }); + process.stdin.destroy(); + if (envelope.options.invalidPtyEvent) return; + events.write( + `${JSON.stringify({ protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "eof" })}\n`, + ); + if (envelope.options.omitPtyExitEvent) { + events.end(); + return; + } + const exitEvent = `${JSON.stringify({ + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "exit", + code: requestedSignal || exit.signal ? null : exit.exitCode, + signal: requestedSignal, + })}\n`; + if (envelope.options.delayedPtyExitEvent) { + const writer = spawn( + process.execPath, + [ + "-e", + `setTimeout(()=>{require('node:fs').writeSync(4,${JSON.stringify(exitEvent)});},1000)`, + ], + { detached: true, stdio: ["ignore", "ignore", "ignore", "ignore", events] }, + ); + writer.unref(); + events.destroy(); + return; + } + events.end(exitEvent); +} + +async function emitConfiguredProcessEvents(events, options, purpose) { + if (!Array.isArray(options.processEventSequence)) return false; + if (options.processEventPurposeKind !== purpose.kind) return false; + if (options.processEventBarrierPath) await waitForFile(options.processEventBarrierPath); + const values = options.processEventSequence.map((type) => { + if (type === "exit") { + return { type, protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, code: 0, signal: null }; + } + if (type === "resized") { + return { type, protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, id: 1 }; + } + return { type, protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION }; + }); + await new Promise((resolve, reject) => { + events + .end( + values + .map((value) => encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, value)) + .join(""), + resolve, + ) + .once("error", reject); + }); + return true; +} + +async function waitForFile(file) { + try { + await readFile(file); + return; + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + await new Promise((resolve, reject) => { + const watcher = watch(path.dirname(file), (event, name) => { + if (name?.toString() !== path.basename(file)) return; + watcher.close(); + resolve(); + }); + watcher.once("error", reject); + void readFile(file).then( + () => { + watcher.close(); + resolve(); + return undefined; + }, + (error) => { + if (error.code !== "ENOENT") { + watcher.close(); + reject(error); + } + return undefined; + }, + ); + }); +} + +async function signal(id, execId, signalName) { + const { options } = CommandRuntimeLifecycleRequestSchema.parse( + JSON.parse(await readStream(process.stdin)), + ); + let pid; + try { + pid = Number(await readFile(execFile(id, execId, options), "utf8")); + } catch (error) { + if (error.code === "ENOENT") return; + throw error; + } + killGroup(pid, signalName); + await waitForProcessExit(pid); + await rm(execFile(id, execId, options), { force: true }); + if (options.signalHelperFailure === "error") throw new Error("fixture signal helper failed"); + if (options.signalHelperFailure === "hang") { + if (options.signalHelperDescendantPidFileName) { + const state = await readState(id, options); + const descendant = spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"], { + stdio: "ignore", + }); + await writeFile( + path.join(state.root, options.signalHelperDescendantPidFileName), + String(descendant.pid), + ); + } + await new Promise(() => setInterval(() => {}, 1_000)); + } +} + +async function waitForProcessExit(pid) { + const deadline = Date.now() + 1_000; + while (processExists(pid)) { + if (Date.now() >= deadline) throw new Error(`Fixture workload remained alive: ${pid}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +function processExists(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error.code === "ESRCH") return false; + throw error; + } +} + +async function applyInspectBarrier(options) { + if (!options.inspectBarrierDirectory) return; + const next = path.join(options.inspectBarrierDirectory, "block-next-inspect"); + try { + await rm(next); + } catch (error) { + if (error.code === "ENOENT") return; + throw error; + } + await writeFile(path.join(options.inspectBarrierDirectory, "inspect-entered"), "entered"); + const release = path.join(options.inspectBarrierDirectory, "release-inspect"); + while (true) { + try { + await readFile(release); + return; + } catch (error) { + if (error.code !== "ENOENT") throw error; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } +} + +async function readState(id, options) { + try { + return JSON.parse(await readFile(stateFile(id, options), "utf8")); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +} + +async function consumePipeWrapperCrash(id, options) { + const state = await readState(id, options); + if (!state || state.pipeWrapperCrashConsumed) return false; + await writeFile( + stateFile(id, options), + JSON.stringify({ ...state, pipeWrapperCrashConsumed: true }), + ); + return true; +} + +function stateFile(id, options) { + return path.join(options.stateDirectory, `${key(id)}.json`); +} + +function execFile(id, execId, options) { + return path.join(options.stateDirectory, `${key(id)}-${execId}.pid`); +} + +function key(value) { + return createHash("sha256").update(value).digest("hex"); +} + +async function resolveCwd(root, relativeCwd) { + const canonicalRoot = await realpath(root); + const canonicalCwd = await realpath(path.resolve(canonicalRoot, relativeCwd ?? ".")); + const relative = path.relative(canonicalRoot, canonicalCwd); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error("Workspace cwd escapes its runtime root"); + } + return canonicalCwd; +} + +function killGroup(pid, signalName) { + try { + process.kill(-pid, signalName); + } catch { + process.kill(pid, signalName); + } +} + +function argument(flag) { + const index = process.argv.indexOf(flag, 2); + return index < 0 ? undefined : process.argv[index + 1]; +} + +function requireArgument(flag) { + const value = argument(flag); + if (!value) throw new Error(`${flag} is required`); + return value; +} + +async function readStream(stream) { + const chunks = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +function writeJson(schema, value) { + process.stdout.write(encodeCommandRuntimeMessage(schema, value)); +} diff --git a/runtimes/fixture/test/fixtures/workspace-runtime-acp-agent.mjs b/runtimes/fixture/test/fixtures/workspace-runtime-acp-agent.mjs new file mode 100644 index 0000000000..fe668b0021 --- /dev/null +++ b/runtimes/fixture/test/fixtures/workspace-runtime-acp-agent.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import readline from "node:readline"; + +if (process.argv.includes("--version")) { + process.stdout.write("workspace-runtime-fixture 1.0.0\n"); + process.exit(0); +} + +const sessions = new Map(); +const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + +for await (const line of lines) { + const message = JSON.parse(line); + if (message.method === "initialize") { + respond(message.id, { + protocolVersion: message.params?.protocolVersion ?? 1, + agentCapabilities: { loadSession: false }, + }); + continue; + } + if (message.method === "session/new") { + const modelId = await readFile(path.join(message.params.cwd, "provider-model.txt"), "utf8") + .then((value) => value.trim()) + .catch(() => "fixture-model"); + const sessionId = `fixture-session-${sessions.size + 1}`; + sessions.set(sessionId, { cwd: message.params.cwd }); + respond(message.id, { + sessionId, + models: { + availableModels: [{ modelId, name: `Fixture Model ${modelId}` }], + currentModelId: modelId, + }, + configOptions: [], + }); + continue; + } + if (message.method === "session/prompt") { + const session = sessions.get(message.params.sessionId); + if (!session) throw new Error(`Unknown fixture session: ${message.params.sessionId}`); + const text = message.params.prompt + .filter((block) => block.type === "text") + .map((block) => block.text) + .join(""); + await writeFile(path.join(session.cwd, "committed.txt"), `${text}\n`); + await writeFile(path.join(session.cwd, "stdio-agent-output.txt"), `${text}\n`); + await writeFile( + path.join(session.cwd, "stdio-agent-env.txt"), + `${process.env.PASEO_DAEMON_ONLY_PROVIDER_ENV ?? ""}\n`, + ); + notify("session/update", { + sessionId: message.params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `fixture completed: ${text}` }, + }, + }); + respond(message.id, { stopReason: "end_turn" }); + continue; + } + if (message.method === "session/cancel") { + respond(message.id, {}); + } +} + +function respond(id, result) { + process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`); +} + +function notify(method, params) { + process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`); +} diff --git a/scripts/check-workspace-runtime-boundaries.mjs b/scripts/check-workspace-runtime-boundaries.mjs new file mode 100644 index 0000000000..23aac63939 --- /dev/null +++ b/scripts/check-workspace-runtime-boundaries.mjs @@ -0,0 +1,558 @@ +#!/usr/bin/env node + +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import ts from "typescript"; + +const SERVER_ROOT = "packages/server/src/server"; +const CORE_SOURCE_ROOTS = [ + "packages/server/src", + "packages/cli/src", + "packages/desktop/src", + "packages/app/src", +]; +const IN_REPO_RUNTIME_ROOTS = ["runtimes/fixture"]; +const migratedOwners = [ + "/session/files/", + "/session/git-mutation/", + "/session/provider/", + "/session/workspace-git-observer/", + "/session/workspace-provisioning/", + "/session/workspace-scripts/", +]; +const hostModules = new Set(["child_process", "fs", "fs/promises", "module", "which"]); +const auditedNonliteralServerLoads = new Set([ + "packages/server/src/server/plugins/plugin-process.ts|runtimeRequire|nodeRequire|load", + "packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts|resolveRuntimeCommand|moduleRequire|resolve", + "packages/server/src/server/speech/providers/local/sherpa/sherpa-runtime-env.ts|resolveSherpaLoaderEnv|require|resolve", + "packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts|loadWithRequire|requireFn|load", +]); + +export async function findWorkspaceBoundaryViolations(repoRoot) { + const files = [ + ...( + await Promise.all(CORE_SOURCE_ROOTS.map((root) => collectSources(path.join(repoRoot, root)))) + ).flat(), + ...( + await Promise.all( + IN_REPO_RUNTIME_ROOTS.map((root) => collectSources(path.join(repoRoot, root))), + ) + ).flat(), + ]; + const modules = await Promise.all( + files.map(async (filename) => { + const source = await readFile(filename, "utf8"); + return { filename, sourceFile: parseSource(filename, source) }; + }), + ); + const publicOwners = new Set( + modules + .filter(({ sourceFile }) => staticSpecifiers(sourceFile).some(isPublicEntryPoint)) + .map(({ filename }) => toRepoPath(repoRoot, filename)), + ); + const violations = []; + for (const runtimeRoot of IN_REPO_RUNTIME_ROOTS) { + const ownedModules = modules.filter(({ filename }) => + toRepoPath(repoRoot, filename).startsWith(`${runtimeRoot}/`), + ); + const duplicateHelper = duplicateWorkspaceHelperViolation(repoRoot, runtimeRoot, ownedModules); + if (duplicateHelper) violations.push(duplicateHelper); + } + for (const module of modules) { + violations.push(...moduleViolations(repoRoot, module, publicOwners)); + } + return violations.sort((left, right) => + `${left.file}:${left.import}:${left.rule}`.localeCompare( + `${right.file}:${right.import}:${right.rule}`, + ), + ); +} + +function moduleViolations(repoRoot, { filename, sourceFile }, publicOwners) { + const importer = toRepoPath(repoRoot, filename); + const external = IN_REPO_RUNTIME_ROOTS.some((root) => importer.startsWith(`${root}/`)); + const testFile = isTestFile(importer); + const serverProduction = importer.startsWith(`${SERVER_ROOT}/`) && !testFile; + const governed = + external || (!testFile && (ownsMigratedSurface(importer) || publicOwners.has(importer))); + const violations = []; + for (const specifier of staticSpecifiers(sourceFile)) { + if (isExternalRuntimeAccess(importer, specifier)) { + violations.push(violation(importer, specifier, "server-runtime-access")); + } + if (!testFile && isForbiddenInternal(importer, specifier)) { + violations.push(violation(importer, specifier, "module-entrypoint")); + } + if (external && !testFile && isExternalEscape(importer, specifier)) { + violations.push(violation(importer, specifier, "external-runtime-contract")); + } + } + for (const load of moduleLoads(sourceFile)) { + violations.push(...loadViolations({ importer, external, governed, serverProduction, load })); + } + return violations; +} + +function loadViolations({ importer, external, governed, serverProduction, load }) { + if (load.specifier === null) { + const audited = isAuditedNonliteralServerLoad(importer, load); + return external || (serverProduction && !audited) + ? [violation(importer, "", "nonliteral-module-load")] + : []; + } + const violations = []; + if (isExternalRuntimeAccess(importer, load.specifier)) { + violations.push(violation(importer, load.specifier, "server-runtime-access")); + } + if (isForbiddenInternal(importer, load.specifier)) { + violations.push(violation(importer, load.specifier, "module-entrypoint")); + } + if (governed && !external && isHostCapability(load.specifier)) { + violations.push(violation(importer, load.specifier, "workspace-host-access")); + } + if (external && isExternalEscape(importer, load.specifier)) { + violations.push(violation(importer, load.specifier, "external-runtime-contract")); + } + return violations; +} + +function implementsWorkspaceHelper(source) { + return ["fs-stat", "fs-list", "fs-read", "fs-write"].every((command) => source.includes(command)); +} + +function duplicateWorkspaceHelperViolation(repoRoot, runtimeRoot, modules) { + const combinedSource = modules.map(({ sourceFile }) => sourceFile.text).join("\n"); + if (!implementsWorkspaceHelper(combinedSource)) return null; + const singleFileOwner = modules.find(({ sourceFile }) => + implementsWorkspaceHelper(sourceFile.text), + ); + const owner = singleFileOwner ? toRepoPath(repoRoot, singleFileOwner.filename) : runtimeRoot; + return violation(owner, "workspace-helper protocol", "duplicate-workspace-helper"); +} + +function parseSource(filename, source) { + return ts.createSourceFile( + filename, + source, + ts.ScriptTarget.Latest, + true, + filename.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); +} + +function staticSpecifiers(sourceFile) { + const specifiers = []; + for (const statement of sourceFile.statements) { + if ( + (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) && + statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier) + ) { + specifiers.push(statement.moduleSpecifier.text); + } + } + return specifiers; +} + +function moduleLoads(sourceFile) { + const loads = []; + const loaderAliases = moduleLoaderAliases(sourceFile); + visit(sourceFile); + return loads; + + function visit(node) { + if (ts.isCallExpression(node)) { + const load = moduleLoad(node.expression, loaderAliases); + if (!load) { + ts.forEachChild(node, visit); + return; + } + const argument = node.arguments.length === 1 ? node.arguments[0] : undefined; + loads.push({ + ...load, + owner: enclosingFunctionName(node), + specifier: argument ? literalModuleSpecifier(argument) : null, + }); + } + ts.forEachChild(node, visit); + } +} + +function moduleLoaderAliases(sourceFile) { + const aliases = new Set(["require"]); + const declarations = variableDeclarations(sourceFile); + const moduleObjects = moduleObjectAliases(sourceFile, declarations); + const factories = createRequireAliases(sourceFile, declarations, moduleObjects); + collectNodeRequireParameters(sourceFile, aliases); + let changed = true; + while (changed) { + changed = false; + for (const declaration of declarations) { + if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue; + const aliasesLoader = + ts.isIdentifier(declaration.initializer) && aliases.has(declaration.initializer.text); + const createsLoader = isCreateRequireCall(declaration.initializer, factories, moduleObjects); + if ((aliasesLoader || createsLoader) && !aliases.has(declaration.name.text)) { + aliases.add(declaration.name.text); + changed = true; + } + } + } + return aliases; +} + +function isCreateRequireCall(expression, factories, moduleObjects) { + if (!ts.isCallExpression(expression)) return false; + return ( + (ts.isIdentifier(expression.expression) && factories.has(expression.expression.text)) || + isCreateRequireProperty(expression.expression, moduleObjects) + ); +} + +function variableDeclarations(sourceFile) { + const declarations = []; + visit(sourceFile); + return declarations; + + function visit(node) { + if (ts.isVariableDeclaration(node)) declarations.push(node); + ts.forEachChild(node, visit); + } +} + +function literalModuleSpecifier(expression) { + if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) { + return expression.text; + } + return null; +} + +function moduleLoad(expression, loaderAliases) { + if (expression.kind === ts.SyntaxKind.ImportKeyword) { + return { loader: "import", operation: "import" }; + } + if (ts.isIdentifier(expression) && loaderAliases.has(expression.text)) { + return { loader: expression.text, operation: "load" }; + } + if ( + ts.isPropertyAccessExpression(expression) && + ts.isIdentifier(expression.expression) && + loaderAliases.has(expression.expression.text) && + expression.name.text === "resolve" + ) { + return { loader: expression.expression.text, operation: "resolve" }; + } + return null; +} + +function createRequireAliases(sourceFile, declarations, moduleObjects) { + const aliases = importedCreateRequireAliases(sourceFile); + let changed = true; + while (changed) { + changed = addCreateRequireAliases(aliases, moduleObjects, declarations); + } + return aliases; +} + +function importedCreateRequireAliases(sourceFile) { + const aliases = new Set(); + for (const statement of sourceFile.statements) { + if (!isNamedModuleImport(statement)) continue; + for (const element of statement.importClause.namedBindings.elements) { + if ((element.propertyName ?? element.name).text === "createRequire") { + aliases.add(element.name.text); + } + } + } + return aliases; +} + +function addCreateRequireAliases(aliases, moduleObjects, declarations) { + let changed = false; + for (const declaration of declarations) { + for (const alias of createRequireBindingNames(declaration, aliases, moduleObjects)) { + if (aliases.has(alias)) continue; + aliases.add(alias); + changed = true; + } + } + return changed; +} + +function createRequireBindingNames(declaration, aliases, moduleObjects) { + if (!declaration.initializer) return []; + if (ts.isIdentifier(declaration.name)) { + const initializer = declaration.initializer; + const factory = + (ts.isIdentifier(initializer) && aliases.has(initializer.text)) || + isCreateRequireProperty(initializer, moduleObjects); + return factory ? [declaration.name.text] : []; + } + if (!ts.isObjectBindingPattern(declaration.name)) return []; + if (!isModuleObjectExpression(declaration.initializer, moduleObjects)) return []; + return declaration.name.elements + .filter( + (element) => + ts.isIdentifier(element.name) && bindingPropertyName(element) === "createRequire", + ) + .map((element) => element.name.text); +} + +function isNamedModuleImport(statement) { + return ( + ts.isImportDeclaration(statement) && + statement.importClause?.namedBindings && + ts.isNamedImports(statement.importClause.namedBindings) && + ts.isStringLiteral(statement.moduleSpecifier) && + ["module", "node:module"].includes(statement.moduleSpecifier.text) + ); +} + +function moduleObjectAliases(sourceFile, declarations) { + const aliases = new Set(); + for (const statement of sourceFile.statements) { + if ( + ts.isImportDeclaration(statement) && + statement.importClause?.namedBindings && + ts.isNamespaceImport(statement.importClause.namedBindings) && + ts.isStringLiteral(statement.moduleSpecifier) && + ["module", "node:module"].includes(statement.moduleSpecifier.text) + ) { + aliases.add(statement.importClause.namedBindings.name.text); + } + } + let changed = true; + while (changed) { + changed = false; + for (const declaration of declarations) { + if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue; + if ( + !isModuleObjectExpression(declaration.initializer, aliases) || + aliases.has(declaration.name.text) + ) { + continue; + } + aliases.add(declaration.name.text); + changed = true; + } + } + return aliases; +} + +function isCreateRequireProperty(expression, moduleObjects) { + return ( + staticPropertyName(expression) === "createRequire" && + isModuleObjectExpression(expression.expression, moduleObjects) + ); +} + +function isModuleObjectExpression(expression, moduleObjects) { + return ( + (ts.isIdentifier(expression) && moduleObjects.has(expression.text)) || + isModuleRequireCall(expression) + ); +} + +function staticPropertyName(expression) { + if (ts.isPropertyAccessExpression(expression)) return expression.name.text; + if (ts.isElementAccessExpression(expression) && expression.argumentExpression) { + return literalModuleSpecifier(expression.argumentExpression); + } + return null; +} + +function bindingPropertyName(element) { + const property = element.propertyName ?? element.name; + if (ts.isIdentifier(property) || ts.isStringLiteral(property)) return property.text; + if (ts.isComputedPropertyName(property)) return literalModuleSpecifier(property.expression); + return null; +} + +function isModuleRequireCall(expression) { + return ( + ts.isCallExpression(expression) && + ts.isIdentifier(expression.expression) && + expression.expression.text === "require" && + expression.arguments.length === 1 && + ts.isStringLiteral(expression.arguments[0]) && + ["module", "node:module"].includes(expression.arguments[0].text) + ); +} + +function collectNodeRequireParameters(sourceFile, aliases) { + visit(sourceFile); + function visit(node) { + if ( + ts.isParameter(node) && + ts.isIdentifier(node.name) && + node.type?.getText() === "NodeRequire" + ) { + aliases.add(node.name.text); + } + ts.forEachChild(node, visit); + } +} + +function enclosingFunctionName(node) { + for (let current = node.parent; current; current = current.parent) { + if (ts.isFunctionDeclaration(current) && current.name) return current.name.text; + if (ts.isMethodDeclaration(current) && ts.isIdentifier(current.name)) return current.name.text; + if ( + (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) && + ts.isVariableDeclaration(current.parent) && + ts.isIdentifier(current.parent.name) + ) { + return current.parent.name.text; + } + } + return ""; +} + +function isAuditedNonliteralServerLoad(importer, load) { + return auditedNonliteralServerLoads.has( + [importer, load.owner, load.loader, load.operation].join("|"), + ); +} + +function isPublicEntryPoint(specifier) { + return /workspace-(?:runtime|helper)\/index\.(?:js|ts)$/.test(specifier); +} + +function ownsMigratedSurface(importer) { + return !isTestFile(importer) && migratedOwners.some((owner) => importer.includes(owner)); +} + +function isHostCapability(specifier) { + const normalized = specifier.startsWith("node:") ? specifier.slice(5) : specifier; + return ( + hostModules.has(normalized) || /(?:^|\/)utils\/run-git-command\.(?:js|ts)$/.test(normalized) + ); +} + +function isForbiddenInternal(importer, specifier) { + const target = resolveSpecifier(importer, specifier); + if (!target || !target.includes("/internal/")) return false; + if (target.includes("/provider-probe/internal/")) { + return !( + importer.endsWith("/provider-probe/index.ts") || + importer.includes("/provider-probe/internal/") + ); + } + if (target.includes("/workspace-runtime/command/internal/")) { + return !( + importer.endsWith("/workspace-runtime/command/index.ts") || + importer.includes("/workspace-runtime/command/internal/") + ); + } + if (target.includes("/workspace-runtime/git-observation/internal/")) { + return !( + importer.endsWith("/workspace-runtime/git-observation/index.ts") || + importer.includes("/workspace-runtime/git-observation/internal/") || + importer.endsWith("/workspace-runtime/internal/service.ts") + ); + } + if (target.includes("/workspace-runtime/internal/")) { + return !( + importer.endsWith("/workspace-runtime/index.ts") || + importer.includes("/workspace-runtime/internal/") + ); + } + if (target.includes("/workspace-helper/internal/")) { + return true; + } + return false; +} + +function isExternalEscape(importer, specifier) { + const normalized = specifier.startsWith("node:") ? specifier.slice(5) : specifier; + if (normalized === "module") return true; + if (specifier.startsWith("node:") || hostModules.has(specifier) || specifier === "node-pty") { + return false; + } + if ( + specifier === "@getpaseo/workspace-runtime-contract" || + specifier === "@getpaseo/workspace-helper" + ) + return false; + if (path.posix.isAbsolute(specifier) || specifier.startsWith("file:")) return true; + if (specifier.startsWith(".")) { + const target = path.posix.normalize(path.posix.join(path.posix.dirname(importer), specifier)); + const owner = IN_REPO_RUNTIME_ROOTS.find((root) => importer.startsWith(`${root}/`)); + return !owner || !target.startsWith(`${owner}/`); + } + return ( + specifier === "@getpaseo/server" || + specifier.startsWith("@getpaseo/server/") || + specifier.includes("packages/server/") || + specifier.includes("/internal/") + ); +} + +function isExternalRuntimeAccess(importer, specifier) { + if (!CORE_SOURCE_ROOTS.some((root) => importer.startsWith(`${root}/`))) return false; + if (/^@getpaseo\/(?:docker|fixture|srt)-workspace-runtime(?:\/|$)/u.test(specifier)) { + return true; + } + if (/paseo-workspace-runtime-(?:docker|srt)|runtimes\/(?:docker|srt)(?:\/|$)/u.test(specifier)) { + return true; + } + const target = resolveSpecifier(importer, specifier); + return IN_REPO_RUNTIME_ROOTS.some( + (runtimeRoot) => target === runtimeRoot || target.startsWith(`${runtimeRoot}/`), + ); +} + +function resolveSpecifier(importer, specifier) { + if (specifier.startsWith(".")) { + return path.posix.normalize(path.posix.join(path.posix.dirname(importer), specifier)); + } + return specifier.replace(/^@getpaseo\/server\/?/, `${SERVER_ROOT}/`); +} + +function isTestFile(filename) { + return /\.(?:test|spec)\.[^.]+$/.test(filename); +} + +function violation(file, imported, rule) { + return { + file, + import: imported, + rule, + message: `${file}: ${rule} forbids ${imported}`, + }; +} + +async function collectSources(directory) { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } + const nested = await Promise.all( + entries.map((entry) => { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) return collectSources(target); + return [".js", ".mjs", ".ts", ".tsx"].includes(path.extname(entry.name)) ? [target] : []; + }), + ); + return nested.flat(); +} + +function toRepoPath(repoRoot, filename) { + return path.relative(repoRoot, filename).split(path.sep).join("/"); +} + +async function main() { + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const violations = await findWorkspaceBoundaryViolations(repoRoot); + for (const item of violations) process.stderr.write(`${item.message}\n`); + if (violations.length > 0) process.exitCode = 1; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await main(); +} diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 8ba01afbe6..9b36a3d934 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -119,6 +119,10 @@ test("focused contracts stay inside existing required checks", () => { assert.doesNotMatch(changes, /Install dependencies|npm run build/); assert.match(server, /test:hub-cli-contract/); + assert.match(server, /npm test --workspace=@getpaseo\/workspace-runtime-contract/); + assert.match(server, /npm test --workspace=@getpaseo\/workspace-helper/); + assert.match(server, /node --test scripts\/workspace-runtime-standalone\.test\.mjs/); + assert.match(server, /node --test scripts\/server-clean-build\.test\.mjs/); assert.match(server, /npm run test --workspace=@getpaseo\/server/); assert.ok(!jobs.has("hub-cli-contract")); @@ -159,7 +163,13 @@ test("PR routing declares stable behavior ownership", () => { ], quality: ["**/*.{cjs,js,json,jsx,mjs,ts,tsx}", "packages/expo-two-way-audio/**"], hub: ["packages/cli/src/commands/hub/**", "packages/server/src/server/hub/**"], - server: ["packages/server/**", "packages/app/e2e/support/fixtures/recording.*"], + server: [ + "packages/server/**", + "runtimes/fixture/**", + "packages/workspace-helper/**", + "packages/workspace-runtime-contract/**", + "packages/app/e2e/support/fixtures/recording.*", + ], desktop: [ "packages/desktop/**", "packages/app/src/desktop/**", diff --git a/scripts/server-clean-build.test.mjs b/scripts/server-clean-build.test.mjs new file mode 100644 index 0000000000..e4c6648f3c --- /dev/null +++ b/scripts/server-clean-build.test.mjs @@ -0,0 +1,197 @@ +import { execFile } from "node:child_process"; +import { access, cp, mkdir, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { promisify } from "node:util"; + +const run = promisify(execFile); +const repoRoot = path.resolve(import.meta.dirname, ".."); +const packageDirectories = [ + "packages/highlight", + "packages/plugin", + "packages/relay", + "packages/protocol", + "packages/client", + "packages/workspace-runtime-contract", + "packages/workspace-helper", + "packages/server", + "packages/cli", +]; + +test("build:server:clean constructs public prerequisites without runtime implementations", async (t) => { + const isolatedRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-server-clean-build-")); + t.after(() => rm(isolatedRoot, { recursive: true, force: true })); + + await copyBuildCheckout(isolatedRoot); + await createNodeModulesOverlay(isolatedRoot); + await createPackageNodeModulesOverlays(isolatedRoot); + + for (const relativePath of [ + "packages/workspace-runtime-contract/dist", + "packages/workspace-helper/dist", + "packages/server/dist", + ]) { + await assert.rejects(access(path.join(isolatedRoot, relativePath)), relativePath); + } + await assert.rejects(access(path.join(isolatedRoot, "runtimes"))); + + try { + await run("npm", ["run", "build:server:clean"], { + cwd: isolatedRoot, + timeout: 120_000, + }); + } catch (error) { + assert.fail(`${error.stdout ?? ""}\n${error.stderr ?? ""}`); + } + + for (const relativePath of [ + "packages/workspace-runtime-contract/dist/index.js", + "packages/workspace-helper/dist/index.js", + "packages/server/dist/server/server/exports.js", + "packages/cli/dist/index.js", + ]) { + await access(path.join(isolatedRoot, relativePath)); + } + await assert.rejects(access(path.join(isolatedRoot, "runtimes"))); +}); + +test("public runtime packages construct complete tarballs from source", async (t) => { + const isolatedRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-runtime-packages-")); + t.after(() => rm(isolatedRoot, { recursive: true, force: true })); + + await copyBuildCheckout(isolatedRoot); + await createNodeModulesOverlay(isolatedRoot); + await createPackageNodeModulesOverlays(isolatedRoot); + + for (const relativePath of [ + "packages/workspace-runtime-contract/dist", + "packages/workspace-helper/dist", + ]) { + await assert.rejects(access(path.join(isolatedRoot, relativePath)), relativePath); + } + + const packDirectory = path.join(isolatedRoot, "packs"); + await mkdir(packDirectory); + const packages = [ + { + workspace: "@getpaseo/workspace-runtime-contract", + files: ["dist/index.js", "dist/index.d.ts"], + }, + { + workspace: "@getpaseo/workspace-helper", + files: ["dist/index.js", "dist/index.d.ts", "dist/executable.mjs"], + executable: "dist/executable.mjs", + }, + ]; + + for (const expected of packages) { + const packed = JSON.parse( + ( + await run( + "npm", + [ + "pack", + "--json", + `--workspace=${expected.workspace}`, + "--pack-destination", + packDirectory, + ], + { cwd: isolatedRoot, timeout: 120_000 }, + ) + ).stdout, + )[0]; + const files = new Map(packed.files.map((file) => [file.path, file])); + for (const expectedFile of expected.files) assert.ok(files.has(expectedFile), expectedFile); + if (expected.executable) assert.equal(files.get(expected.executable)?.mode, 0o755); + await access(path.join(packDirectory, packed.filename)); + } +}); + +async function copyBuildCheckout(isolatedRoot) { + await Promise.all([ + ...["package.json", "package-lock.json", "tsconfig.base.json", "tsconfig.json"].map((file) => + cp(path.join(repoRoot, file), path.join(isolatedRoot, file)), + ), + cp(path.join(repoRoot, "scripts"), path.join(isolatedRoot, "scripts"), { + recursive: true, + filter: excludesBuildOutput, + }), + ...packageDirectories.map(async (relativeDirectory) => { + const target = path.join(isolatedRoot, relativeDirectory); + await mkdir(path.dirname(target), { recursive: true }); + await cp(path.join(repoRoot, relativeDirectory), target, { + recursive: true, + filter: excludesBuildOutput, + }); + }), + ]); +} + +function excludesBuildOutput(source) { + return ( + !source.endsWith(`${path.sep}dist`) && + !source.endsWith(`${path.sep}node_modules`) && + !source.endsWith(`${path.sep}test-results`) + ); +} + +async function createNodeModulesOverlay(isolatedRoot) { + const sourceModules = path.join(repoRoot, "node_modules"); + const targetModules = path.join(isolatedRoot, "node_modules"); + const workspaces = await isolatedWorkspaces(isolatedRoot); + const internalScopes = new Set(workspaces.map(({ name }) => name.split("/")[0])); + await mkdir(targetModules); + await overlayModules(sourceModules, targetModules, internalScopes); + + for (const { relativeDirectory, name } of workspaces) { + const [scope, packageName] = name.split("/"); + const internalScope = path.join(targetModules, scope); + await mkdir(internalScope, { recursive: true }); + await symlink( + path.join(isolatedRoot, relativeDirectory), + path.join(internalScope, packageName), + "dir", + ); + } +} + +async function createPackageNodeModulesOverlays(isolatedRoot) { + const internalScopes = new Set( + (await isolatedWorkspaces(isolatedRoot)).map(({ name }) => name.split("/")[0]), + ); + for (const relativeDirectory of packageDirectories) { + const sourceModules = path.join(repoRoot, relativeDirectory, "node_modules"); + try { + await access(sourceModules); + } catch { + continue; + } + const targetModules = path.join(isolatedRoot, relativeDirectory, "node_modules"); + await mkdir(targetModules); + await overlayModules(sourceModules, targetModules, internalScopes); + } +} + +async function isolatedWorkspaces(isolatedRoot) { + return Promise.all( + packageDirectories.map(async (relativeDirectory) => ({ + relativeDirectory, + name: JSON.parse( + await readFile(path.join(isolatedRoot, relativeDirectory, "package.json"), "utf8"), + ).name, + })), + ); +} + +async function overlayModules(sourceModules, targetModules, internalScopes) { + for (const entry of await readdir(sourceModules, { withFileTypes: true })) { + if (internalScopes.has(entry.name)) continue; + await symlink( + path.join(sourceModules, entry.name), + path.join(targetModules, entry.name), + entry.isDirectory() ? "dir" : "file", + ); + } +} diff --git a/scripts/workspace-runtime-boundaries.test.mjs b/scripts/workspace-runtime-boundaries.test.mjs new file mode 100644 index 0000000000..24428468af --- /dev/null +++ b/scripts/workspace-runtime-boundaries.test.mjs @@ -0,0 +1,590 @@ +import { execFile } from "node:child_process"; +import { + access, + copyFile, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { promisify } from "node:util"; + +import { findWorkspaceBoundaryViolations } from "./check-workspace-runtime-boundaries.mjs"; + +const run = promisify(execFile); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const oxlint = path.join(repoRoot, "node_modules/.bin/oxlint"); + +test("Paseo owns only the public runtime packages and generic fixture", async () => { + const rootPackage = JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf8")); + assert.ok(rootPackage.workspaces.includes("packages/workspace-helper")); + assert.ok(rootPackage.workspaces.includes("packages/workspace-runtime-contract")); + assert.ok(rootPackage.workspaces.includes("runtimes/fixture")); + assert.ok(!rootPackage.workspaces.includes("runtimes/*")); + for (const script of [ + "build:docker-workspace-runtime", + "build:docker-workspace-runtime:clean", + "build:srt-workspace-runtime", + ]) { + assert.equal(rootPackage.scripts?.[script], undefined); + } + + await assert.rejects(access(path.join(repoRoot, "packages/server/src/server/workspace-helper"))); + for (const absent of [ + "packages/docker-workspace-runtime", + "packages/fixture-workspace-runtime", + "packages/srt-workspace-runtime", + "runtimes/docker", + "runtimes/srt", + ]) { + await assert.rejects(access(path.join(repoRoot, absent)), absent); + } + + const helper = JSON.parse( + await readFile(path.join(repoRoot, "packages/workspace-helper/package.json"), "utf8"), + ); + assert.equal(helper.name, "@getpaseo/workspace-helper"); + assert.equal(helper.private, undefined); + assert.equal(helper.bin?.["paseo-workspace-helper"], "./dist/executable.mjs"); + + const server = JSON.parse( + await readFile(path.join(repoRoot, "packages/server/package.json"), "utf8"), + ); + assert.ok(server.dependencies?.["@getpaseo/workspace-helper"]); + assert.ok(server.dependencies?.["@getpaseo/workspace-runtime-contract"]); + for (const runtime of ["docker", "fixture", "srt"]) { + assert.equal(server.dependencies?.[`@getpaseo/${runtime}-workspace-runtime`], undefined); + } + + const fixtureDirectory = path.join(repoRoot, "runtimes/fixture"); + const fixture = JSON.parse(await readFile(path.join(fixtureDirectory, "package.json"), "utf8")); + for (const dependency of ["@getpaseo/workspace-runtime-contract", "@getpaseo/workspace-helper"]) { + assert.match(fixture.dependencies?.[dependency] ?? "", /^\d+\.\d+\.\d+(?:[-+].*)?$/u); + } + const fixtureSources = await collectRuntimeSources(fixtureDirectory); + assert.doesNotMatch( + fixtureSources, + /packages\/(?:server|app|cli|desktop|test-utils)|@getpaseo\/server|docker|sandbox-runtime|\bsrt\b/iu, + ); + + const lockfile = await readFile(path.join(repoRoot, "package-lock.json"), "utf8"); + assert.doesNotMatch( + lockfile, + /node_modules\/@getpaseo\/(?:docker|srt)-workspace-runtime|"runtimes\/(?:docker|srt)"/u, + ); + + for (const relativePath of [ + ".github/ci-paths.yml", + ".github/workflows/ci.yml", + ".github/workflows/docker.yml", + "docs/architecture.md", + "docs/docker.md", + "public-docs/configuration.md", + ]) { + const contents = await readFile(path.join(repoRoot, relativePath), "utf8"); + assert.doesNotMatch(contents, /runtimes\/(?:docker|srt)/u, relativePath); + } +}); + +test("Oxlint rejects static host imports, re-exports, aliases, and internal package paths", async (t) => { + const root = await fixtureRoot(t); + await copyFile(path.join(repoRoot, ".oxlintrc.json"), path.join(root, ".oxlintrc.json")); + await source( + root, + "packages/server/src/server/session/workspace-scripts/forbidden.ts", + 'import { spawn } from "child_process";\n' + + 'import { readFile } from "node:fs/promises";\n' + + 'import { createRequire as makeRequire } from "node:module";\n' + + "const renamedLoader = makeRequire(import.meta.url);\n" + + 'renamedLoader("child_process");\n' + + 'renamedLoader("fs");\n' + + 'renamedLoader("@getpaseo/server/workspace-runtime/internal/service.js");\n' + + 'export * from "@getpaseo/server/workspace-runtime/internal/service.js";\n' + + 'export * from "@getpaseo/server/workspace-runtime/command/internal/command-runtime.js";\n' + + 'export * from "@getpaseo/server/workspace-runtime/git-observation/internal/integration.js";\n' + + 'export { default as helper } from "@getpaseo/workspace-helper/internal/client.js";\n' + + "void spawn; void readFile;\n", + ); + await source( + root, + "runtimes/fixture/src/forbidden.mjs", + 'import "@getpaseo/server/workspace-runtime/command/internal/command-runtime.js";\n' + + 'import { createRequire as externalRequire } from "module";\n' + + "const fixtureLoader = externalRequire(import.meta.url);\n" + + 'fixtureLoader("node:fs");\n' + + 'export * from "@getpaseo/workspace-helper/internal/client.js";\n', + ); + + const error = await captureFailure(run(oxlint, ["packages", "runtimes"], { cwd: root })); + const output = `${error.stdout ?? ""}${error.stderr ?? ""}`; + assert.match(output, /child_process/); + assert.match(output, /node:fs\/promises/); + assert.match(output, /node:module/); + assert.match(output, /module/); + assert.match(output, /workspace-runtime\/internal/); + assert.match(output, /workspace-runtime\/command\/internal/); + assert.match(output, /git-observation\/internal/); + assert.match(output, /workspace-helper\/internal/); +}); + +test("the AST guard rejects computed loading and dynamic host/internal bypasses", async (t) => { + const root = await fixtureRoot(t); + await source( + root, + "packages/server/src/server/session/workspace-scripts/forbidden.ts", + 'import type { WorkspaceRuntimeService } from "../../workspace-runtime/index.js";\n' + + 'require("fs");\n' + + 'import("node:child_process");\n' + + 'const { createRequire: makeLoader } = require("module");\n' + + 'const renamedLoader = makeLoader(import.meta.url); renamedLoader("fs");\n' + + 'require("node:" + "fs/promises");\n' + + "import(`@getpaseo/server/${segment}`);\n", + ); + await source( + root, + "packages/server/src/server/session/business.ts", + 'require("@getpaseo/server/workspace-runtime/command/internal/command-runtime.js");\n', + ); + await source( + root, + "runtimes/fixture/src/forbidden.mjs", + 'require("../../../packages/server/src/server/workspace-runtime/internal/service.js");\n' + + 'import("@getpaseo/" + packageName);\n', + ); + + const violations = await findWorkspaceBoundaryViolations(root); + assert.deepEqual( + violations.map(({ rule }) => rule).sort(), + [ + "external-runtime-contract", + "module-entrypoint", + "module-entrypoint", + "nonliteral-module-load", + "nonliteral-module-load", + "nonliteral-module-load", + "workspace-host-access", + "workspace-host-access", + "workspace-host-access", + "workspace-host-access", + ].sort(), + ); +}); + +test("the AST guard rejects server access to runtime packages and source roots", async (t) => { + const root = await fixtureRoot(t); + await source( + root, + "packages/server/src/server/runtime-bypass.ts", + 'import "@getpaseo/docker-workspace-runtime";\n' + + 'export * from "@getpaseo/srt-workspace-runtime";\n' + + 'import "../../../../../../paseo-workspace-runtime-docker/src/index.ts";\n' + + 'export * from "../../../../../../paseo-workspace-runtime-srt/src/index.mjs";\n' + + 'const packageName = "@getpaseo/" + "fixture-workspace-runtime";\n' + + 'const sourceRoot = "../../../../runtimes/" + "docker/src/index.ts";\n' + + "const load = require;\n" + + "void load(packageName);\n" + + "void import(sourceRoot);\n", + ); + + const violations = await findWorkspaceBoundaryViolations(root); + assert.equal(violations.length, 6); + assert.deepEqual(violations.map(({ rule }) => rule).sort(), [ + "nonliteral-module-load", + "nonliteral-module-load", + "server-runtime-access", + "server-runtime-access", + "server-runtime-access", + "server-runtime-access", + ]); + assert.deepEqual( + new Set(violations.map((violation) => violation.import)), + new Set([ + "@getpaseo/docker-workspace-runtime", + "@getpaseo/srt-workspace-runtime", + "../../../../../../paseo-workspace-runtime-docker/src/index.ts", + "../../../../../../paseo-workspace-runtime-srt/src/index.mjs", + "", + ]), + ); +}); + +test("the AST guard rejects every nonliteral production server load", async (t) => { + const root = await fixtureRoot(t); + await source( + root, + "packages/server/src/server/nonliteral-runtime-load.ts", + 'const target = ["@getpaseo/docker", "-workspace-runtime"].join("");\n' + + "void import(target);\n", + ); + + assert.deepEqual(await findWorkspaceBoundaryViolations(root), [ + { + file: "packages/server/src/server/nonliteral-runtime-load.ts", + import: "", + rule: "nonliteral-module-load", + message: + "packages/server/src/server/nonliteral-runtime-load.ts: nonliteral-module-load forbids ", + }, + ]); +}); + +test("the AST guard follows createRequire factory origins and chained aliases", async (t) => { + const root = await fixtureRoot(t); + await source( + root, + "packages/server/src/server/named-create-require.ts", + 'import { createRequire } from "node:module";\n' + + "const makeLoader = createRequire;\n" + + "const load = makeLoader(import.meta.url);\n" + + "void load(process.env.RUNTIME);\n", + ); + await source( + root, + "packages/server/src/server/namespace-create-require.ts", + 'import * as moduleApi from "node:module";\n' + + "const makeLoader = moduleApi.createRequire;\n" + + "const makeLoaderAlias = makeLoader;\n" + + "const load = makeLoaderAlias(import.meta.url);\n" + + "const loadAlias = load;\n" + + "void loadAlias(process.env.RUNTIME);\n" + + 'const computedFactory = moduleApi["createRequire"];\n' + + "const computedLoad = computedFactory(import.meta.url);\n" + + "void computedLoad(process.env.RUNTIME);\n", + ); + await source( + root, + "packages/server/src/server/commonjs-create-require.ts", + 'const moduleApi = require("node:module");\n' + + "const makeLoader = moduleApi.createRequire;\n" + + "const makeLoaderAlias = makeLoader;\n" + + "const load = makeLoaderAlias(import.meta.url);\n" + + "const loadAlias = load;\n" + + "void loadAlias(process.env.RUNTIME);\n" + + "const computedFactory = moduleApi[`createRequire`];\n" + + "const computedLoad = computedFactory(import.meta.url);\n" + + "void computedLoad(process.env.RUNTIME);\n", + ); + + const violations = await findWorkspaceBoundaryViolations(root); + assert.equal(violations.length, 5); + assert.deepEqual( + violations.map(({ file, rule }) => ({ file, rule })), + [ + { + file: "packages/server/src/server/commonjs-create-require.ts", + rule: "nonliteral-module-load", + }, + { + file: "packages/server/src/server/commonjs-create-require.ts", + rule: "nonliteral-module-load", + }, + { + file: "packages/server/src/server/named-create-require.ts", + rule: "nonliteral-module-load", + }, + { + file: "packages/server/src/server/namespace-create-require.ts", + rule: "nonliteral-module-load", + }, + { + file: "packages/server/src/server/namespace-create-require.ts", + rule: "nonliteral-module-load", + }, + ], + ); +}); + +test("the AST guard follows direct createRequire property calls", async (t) => { + const root = await fixtureRoot(t); + await source( + root, + "packages/server/src/server/direct-create-require.ts", + 'import * as moduleApi from "node:module";\n' + + "const loadA = moduleApi.createRequire(import.meta.url);\n" + + "void loadA(process.env.RUNTIME);\n" + + "\n" + + 'const moduleObject = require("node:module");\n' + + "const loadB = moduleObject.createRequire(import.meta.url);\n" + + "void loadB(process.env.RUNTIME);\n" + + "\n" + + 'const loadC = moduleApi["createRequire"](import.meta.url);\n' + + "void loadC(process.env.RUNTIME);\n" + + "\n" + + "const loadD = moduleApi[`createRequire`](import.meta.url);\n" + + "void loadD(process.env.RUNTIME);\n", + ); + + const violations = await findWorkspaceBoundaryViolations(root); + assert.equal(violations.length, 4); + assert.ok(violations.every(({ rule }) => rule === "nonliteral-module-load")); +}); + +test("audited production loader exceptions are narrow", async (t) => { + const root = await fixtureRoot(t); + await source( + root, + "packages/server/src/server/plugins/plugin-process.ts", + 'import { createRequire } from "node:module";\n' + + 'const nodeRequire = createRequire("package.json");\n' + + "function runtimeRequire(target) {\n" + + " return nodeRequire(target);\n" + + "}\n" + + "function unreviewedPluginLoad(target) {\n" + + " return nodeRequire(target);\n" + + "}\n", + ); + await source( + root, + "packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts", + 'import { createRequire } from "node:module";\n' + + "function resolveRuntimeCommand(target) {\n" + + ' const moduleRequire = createRequire("package.json");\n' + + " return moduleRequire.resolve(target);\n" + + "}\n" + + "function unreviewedCommandLoad(target) {\n" + + ' const moduleRequire = createRequire("package.json");\n' + + " return moduleRequire(target);\n" + + "}\n", + ); + await source( + root, + "packages/server/src/server/speech/providers/local/sherpa/sherpa-runtime-env.ts", + 'import { createRequire } from "node:module";\n' + + "function resolveSherpaLoaderEnv(target) {\n" + + ' const require = createRequire("package.json");\n' + + " return require.resolve(target);\n" + + "}\n", + ); + await source( + root, + "packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts", + "function loadWithRequire(requireFn: NodeRequire, target) {\n" + + " return requireFn(target);\n" + + "}\n", + ); + + assert.deepEqual(await findWorkspaceBoundaryViolations(root), [ + { + file: "packages/server/src/server/plugins/plugin-process.ts", + import: "", + rule: "nonliteral-module-load", + message: + "packages/server/src/server/plugins/plugin-process.ts: nonliteral-module-load forbids ", + }, + { + file: "packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts", + import: "", + rule: "nonliteral-module-load", + message: + "packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts: nonliteral-module-load forbids ", + }, + ]); +}); + +test("the boundary guard rejects a copied helper under any runtime filename", async (t) => { + const root = await fixtureRoot(t); + await source( + root, + "runtimes/fixture/src/innocent-name.mjs", + 'const commands = ["fs-stat", "fs-list", "fs-read", "fs-write"]; void commands;\n', + ); + assert.deepEqual(await findWorkspaceBoundaryViolations(root), [ + { + file: "runtimes/fixture/src/innocent-name.mjs", + import: "workspace-helper protocol", + rule: "duplicate-workspace-helper", + message: + "runtimes/fixture/src/innocent-name.mjs: duplicate-workspace-helper forbids workspace-helper protocol", + }, + ]); +}); + +test("the boundary guard rejects a helper protocol split across runtime files", async (t) => { + const root = await fixtureRoot(t); + await source( + root, + "runtimes/fixture/src/read-commands.mjs", + 'export const commands = ["fs-stat", "fs-list"];\n', + ); + await source( + root, + "runtimes/fixture/src/write-commands.mjs", + 'export const commands = ["fs-read", "fs-write"];\n', + ); + + assert.deepEqual(await findWorkspaceBoundaryViolations(root), [ + { + file: "runtimes/fixture", + import: "workspace-helper protocol", + rule: "duplicate-workspace-helper", + message: "runtimes/fixture: duplicate-workspace-helper forbids workspace-helper protocol", + }, + ]); +}); + +test("the AST guard rejects every statically resolvable provider probe internal import", async (t) => { + const root = await fixtureRoot(t); + await source( + root, + "packages/server/src/server/session/computed.ts", + 'import { createService as direct } from "../provider-probe/internal/service.js";\n' + + 'import { createService as alias } from "@getpaseo/server/provider-probe/internal/service.js";\n' + + 'export { createService } from "../provider-probe/internal/service.js";\n' + + 'const internal = "../provider-probe/" + "internal/service.js";\n' + + "const load = require;\n" + + 'void require("../provider-probe/internal/service.js");\n' + + 'void import("../provider-probe/" + "internal/service.js");\n' + + 'void import(`../provider-probe/${"internal"}/service.js`);\n' + + "void import(internal);\n" + + "void load(internal);\n" + + "void direct; void alias;\n", + ); + + const violations = await findWorkspaceBoundaryViolations(root); + assert.equal(violations.length, 8); + assert.deepEqual( + new Set(violations.map(({ rule }) => rule)), + new Set(["module-entrypoint", "nonliteral-module-load"]), + ); + assert.deepEqual( + new Set(violations.map((violation) => violation.import)), + new Set([ + "../provider-probe/internal/service.js", + "@getpaseo/server/provider-probe/internal/service.js", + "", + ]), + ); +}); + +test("owned integrations, explicit legacy code, and fixture contract imports pass", async (t) => { + const root = await fixtureRoot(t); + await copyFile(path.join(repoRoot, ".oxlintrc.json"), path.join(root, ".oxlintrc.json")); + await source( + root, + "packages/server/src/server/workspace-runtime/index.ts", + 'import { service } from "./internal/service.js"; void service;\n', + ); + await source( + root, + "packages/server/src/server/provider-probe/index.ts", + 'import { createService } from "./internal/service.js";\n' + + 'export { createProbeStore } from "./internal/probe-store.js";\n' + + "void createService;\n", + ); + await source( + root, + "packages/server/src/server/workspace-runtime/command/index.ts", + 'export * from "./internal/command-runtime.js";\n', + ); + await source( + root, + "packages/server/src/server/workspace-runtime/internal/service.ts", + 'import { git } from "../git-observation/internal/integration.js";\n' + + 'import { helper } from "@getpaseo/workspace-helper";\n' + + "export const service = [git, helper];\n", + ); + await source( + root, + "packages/server/src/server/workspace-git-service.ts", + 'import { readFile } from "fs/promises"; void readFile;\n', + ); + await source( + root, + "runtimes/fixture/src/index.mjs", + 'import { CommandRuntimeControlSchema } from "@getpaseo/workspace-runtime-contract";\n' + + 'import { spawn } from "node:child_process";\n' + + 'import("./nested.mjs"); void CommandRuntimeControlSchema; void spawn;\n', + ); + await source( + root, + "packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts", + "export const git = 1;\n", + ); + await source(root, "packages/workspace-helper/src/index.ts", "export const helper = 1;\n"); + await source(root, "runtimes/fixture/src/nested.mjs", "export const nested = 1;\n"); + + assert.deepEqual(await findWorkspaceBoundaryViolations(root), []); + try { + await run(oxlint, ["packages", "runtimes"], { cwd: root }); + } catch (error) { + assert.fail(`${error.stdout ?? ""}${error.stderr ?? ""}`); + } +}); + +test("strict public schemas and helper argv reject root authority independent of syntax", async (t) => { + await run("npm", ["run", "build", "--workspace=@getpaseo/workspace-runtime-contract"], { + cwd: repoRoot, + }); + const contract = await import( + pathToFileURL(path.join(repoRoot, "packages/workspace-runtime-contract/dist/index.js")) + ); + const key = ["ro", "ot"].join(""); + const state = { workspaceId: "workspace-01", lifecycle: "ready", [key]: "/private" }; + const schemaAlias = contract.CommandRuntimeStateSchema; + assert.throws(() => schemaAlias.parse(state), /unrecognized key/i); + assert.throws( + () => + contract.CommandRuntimeLifecycleResponseSchema.parse({ + type: "state", + protocolVersion: 1, + state: { ...state }, + placement: { cwd: "/workspace" }, + }), + /unrecognized key/i, + ); + + const root = await fixtureRoot(t); + const helperError = await captureFailure( + run( + process.execPath, + [ + path.join(repoRoot, "packages/workspace-helper/src/executable.mjs"), + "fs-stat", + `--${key}`, + "/private", + "--path", + ".", + ], + { cwd: root }, + ), + ); + assert.match(helperError.stderr, /Unknown workspace-helper argument: --root/); +}); + +async function captureFailure(promise) { + try { + await promise; + } catch (error) { + return error; + } + assert.fail("Expected command to fail"); +} + +async function fixtureRoot(t) { + const root = await mkdtemp(path.join(tmpdir(), "workspace-boundaries-")); + t.after(() => rm(root, { recursive: true, force: true })); + return root; +} + +async function source(root, relativePath, contents) { + const filename = path.join(root, relativePath); + await mkdir(path.dirname(filename), { recursive: true }); + await writeFile(filename, contents); +} + +async function collectRuntimeSources(directory) { + const entries = await readdir(directory, { recursive: true, withFileTypes: true }); + const sources = await Promise.all( + entries + .filter((entry) => entry.isFile() && /\.(?:js|mjs|ts|tsx)$/u.test(entry.name)) + .map((entry) => readFile(path.join(entry.parentPath, entry.name), "utf8")), + ); + return sources.join("\n"); +} diff --git a/scripts/workspace-runtime-standalone.test.mjs b/scripts/workspace-runtime-standalone.test.mjs new file mode 100644 index 0000000000..47fcb6d807 --- /dev/null +++ b/scripts/workspace-runtime-standalone.test.mjs @@ -0,0 +1,103 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { promisify } from "node:util"; + +const run = promisify(execFile); +const repoRoot = path.resolve(import.meta.dirname, ".."); + +test("the fixture installs and describes from isolated packed dependencies", async (t) => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-fixture-standalone-")); + const managedArtifacts = []; + t.after(async () => { + for (const artifact of managedArtifacts) { + await rm(path.join(repoRoot, artifact.relativePath), { recursive: true, force: true }); + } + for (const artifact of managedArtifacts) { + if (artifact.hiddenPath) { + await rename(artifact.hiddenPath, path.join(repoRoot, artifact.relativePath)); + } + } + await rm(root, { recursive: true, force: true }); + }); + const packs = path.join(root, "packs"); + await mkdir(packs); + + for (const relativePath of buildArtifacts) { + const hiddenPath = path.join(root, "previous-dist", relativePath.replaceAll("/", "__")); + try { + await mkdir(path.dirname(hiddenPath), { recursive: true }); + await rename(path.join(repoRoot, relativePath), hiddenPath); + managedArtifacts.push({ relativePath, hiddenPath }); + } catch (error) { + if (error.code !== "ENOENT") throw error; + managedArtifacts.push({ relativePath, hiddenPath: null }); + } + } + const packageRoots = { + contract: "packages/workspace-runtime-contract", + helper: "packages/workspace-helper", + fixture: "runtimes/fixture", + }; + await run("npm", ["run", "build", "--workspace=@getpaseo/fixture-workspace-runtime"], { + cwd: repoRoot, + }); + const tarballs = {}; + for (const [name, relativeRoot] of Object.entries(packageRoots)) { + const result = JSON.parse( + ( + await run("npm", ["pack", "--json", "--pack-destination", packs], { + cwd: path.join(repoRoot, relativeRoot), + }) + ).stdout, + ); + tarballs[name] = path.join(packs, result[0].filename); + } + + const project = path.join(root, "fixture"); + await mkdir(project); + await writeFile( + path.join(project, "package.json"), + JSON.stringify({ + private: true, + dependencies: { + "@getpaseo/workspace-runtime-contract": `file:${tarballs.contract}`, + "@getpaseo/workspace-helper": `file:${tarballs.helper}`, + "@getpaseo/fixture-workspace-runtime": `file:${tarballs.fixture}`, + }, + }), + ); + await run("npm", ["install", "--ignore-scripts"], { + cwd: project, + env: { ...process.env, npm_config_cache: path.join(root, "npm-cache") }, + }); + const bin = path.join(project, "node_modules/.bin/paseo-fixture-workspace-runtime"); + const description = JSON.parse((await run(bin, ["describe"], { cwd: project })).stdout); + assert.deepEqual(description, { + protocolVersion: 1, + modes: ["pipes", "pty"], + reconcile: false, + }); + const bundledHelper = path.join( + project, + "node_modules/@getpaseo/fixture-workspace-runtime/dist/paseo-workspace-helper", + ); + assert.deepEqual(JSON.parse((await run(bundledHelper, ["describe"], { cwd: project })).stdout), { + protocolVersion: 1, + version: 1, + capabilities: ["files", "watch", "resolve-command"], + }); + assert.match( + await import("node:fs/promises").then(({ realpath }) => realpath(bin)), + /node_modules\/@getpaseo\/fixture-workspace-runtime\//u, + ); +}); + +const buildArtifacts = [ + "packages/workspace-runtime-contract/dist", + "packages/workspace-helper/dist", + "runtimes/fixture/dist", +];